repository.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. "use strict";
  2. const fs = require("fs");
  3. const fsp = fs.promises;
  4. const path = require("path");
  5. const STATUS_SET = new Set(["pending", "in-progress", "completed", "bug"]);
  6. const NODE_TYPE_SET = new Set([
  7. "prompt",
  8. "sub-goal",
  9. "thought",
  10. "action",
  11. "observation",
  12. "conclusion",
  13. ]);
  14. const EDGE_TYPE_SET = new Set(["hierarchy", "dependency", "reference", "merge"]);
  15. function nowIso() {
  16. return new Date().toISOString();
  17. }
  18. function toStringSafe(value, maxLength) {
  19. if (value === undefined || value === null) return "";
  20. return String(value).trim().slice(0, maxLength);
  21. }
  22. function toNullableString(value, maxLength) {
  23. if (value === undefined || value === null || value === "") return null;
  24. const next = String(value).trim().slice(0, maxLength);
  25. return next || null;
  26. }
  27. function isValidIso(value) {
  28. if (value === null || value === undefined || value === "") return false;
  29. return Number.isFinite(Date.parse(String(value)));
  30. }
  31. function normalizeIso(value, fallback = null) {
  32. if (value === null || value === undefined || value === "") return fallback;
  33. const iso = String(value).trim();
  34. return isValidIso(iso) ? new Date(iso).toISOString() : fallback;
  35. }
  36. function normalizeStatus(value) {
  37. return STATUS_SET.has(value) ? value : "pending";
  38. }
  39. function normalizeNodeType(value, hasParent) {
  40. if (NODE_TYPE_SET.has(value)) return value;
  41. return hasParent ? "thought" : "prompt";
  42. }
  43. function createId(prefix = "node") {
  44. const rand = Math.random().toString(36).slice(2, 8);
  45. return `${prefix}-${Date.now()}-${rand}`;
  46. }
  47. function extractSessionKey(notes) {
  48. if (!notes) return null;
  49. const text = String(notes);
  50. const match = text.match(/sessionKey=([^\n\r]+)/);
  51. return match && match[1] ? match[1].trim() : null;
  52. }
  53. function extractContentFromNotes(notes) {
  54. if (!notes) return "";
  55. const text = String(notes);
  56. const match = text.match(/(?:^|\n)content=([^\n]+)/);
  57. if (match && match[1]) return match[1].trim().slice(0, 5000);
  58. return "";
  59. }
  60. function normalizeDiagnostics(input) {
  61. const source = input && typeof input === "object" ? input : {};
  62. const tokenUsage = source.tokenUsage && typeof source.tokenUsage === "object" ? source.tokenUsage : {};
  63. const stopReason = toNullableString(source.stopReason, 32);
  64. return {
  65. rawInputLength: Number.isFinite(source.rawInputLength) ? Math.max(0, Math.floor(source.rawInputLength)) : null,
  66. tokenUsage: {
  67. promptTokens: Number.isFinite(tokenUsage.promptTokens) ? Math.max(0, Math.floor(tokenUsage.promptTokens)) : null,
  68. completionTokens: Number.isFinite(tokenUsage.completionTokens)
  69. ? Math.max(0, Math.floor(tokenUsage.completionTokens))
  70. : null,
  71. totalTokens: Number.isFinite(tokenUsage.totalTokens) ? Math.max(0, Math.floor(tokenUsage.totalTokens)) : null,
  72. },
  73. stopReason,
  74. latencyMs: Number.isFinite(source.latencyMs) ? Math.max(0, Math.floor(source.latencyMs)) : null,
  75. };
  76. }
  77. function normalizeNode(input, fallback = {}) {
  78. const parentId = toNullableString(input.parentId, 180);
  79. const created = normalizeIso(input?.timestamps?.created || input.createdAt, nowIso());
  80. const started = normalizeIso(input?.timestamps?.started || input.startTime, null);
  81. const completed = normalizeIso(input?.timestamps?.completed || input.endTime, null);
  82. const updated = normalizeIso(input.updatedAt, nowIso());
  83. const status = normalizeStatus(input.status);
  84. const node = {
  85. id: toStringSafe(input.id || fallback.id || createId("task"), 180),
  86. conversationId: toNullableString(input.conversationId || fallback.conversationId, 240),
  87. parentId,
  88. nodeType: normalizeNodeType(input.nodeType || fallback.nodeType, Boolean(parentId)),
  89. name: toStringSafe(input.name || fallback.name || "未命名节点", 200) || "未命名节点",
  90. status,
  91. content: toStringSafe(input.content || fallback.content, 5000),
  92. timestamps: {
  93. created,
  94. started,
  95. completed,
  96. },
  97. llmDiagnostics: normalizeDiagnostics(input.llmDiagnostics || fallback.llmDiagnostics),
  98. plannedApproach: toStringSafe(input.plannedApproach || fallback.plannedApproach, 5000),
  99. bugDetails: toStringSafe(input.bugDetails || fallback.bugDetails, 5000),
  100. notes: toStringSafe(input.notes || fallback.notes, 8000),
  101. createdAt: created,
  102. updatedAt: updated,
  103. };
  104. if (node.status === "in-progress" && !node.timestamps.started) {
  105. node.timestamps.started = nowIso();
  106. }
  107. if ((node.status === "completed" || node.status === "bug") && !node.timestamps.completed) {
  108. node.timestamps.completed = nowIso();
  109. }
  110. return node;
  111. }
  112. function normalizeEdge(input) {
  113. const type = EDGE_TYPE_SET.has(input.type) ? input.type : "reference";
  114. return {
  115. id: toStringSafe(input.id || createId("edge"), 180),
  116. from: toStringSafe(input.from, 180),
  117. to: toStringSafe(input.to, 180),
  118. type,
  119. createdAt: normalizeIso(input.createdAt, nowIso()),
  120. };
  121. }
  122. function topologicalConversationPropagation(nodes) {
  123. const byId = new Map(nodes.map((node) => [node.id, node]));
  124. for (let i = 0; i < nodes.length + 2; i += 1) {
  125. let changed = false;
  126. for (const node of nodes) {
  127. if (node.conversationId) continue;
  128. if (!node.parentId) continue;
  129. const parent = byId.get(node.parentId);
  130. if (parent && parent.conversationId) {
  131. node.conversationId = parent.conversationId;
  132. changed = true;
  133. }
  134. }
  135. if (!changed) break;
  136. }
  137. for (const node of nodes) {
  138. if (!node.conversationId && !node.parentId) {
  139. node.conversationId = `legacy:${node.id}`;
  140. }
  141. }
  142. for (let i = 0; i < nodes.length + 2; i += 1) {
  143. let changed = false;
  144. for (const node of nodes) {
  145. if (node.conversationId) continue;
  146. if (!node.parentId) continue;
  147. const parent = byId.get(node.parentId);
  148. if (parent && parent.conversationId) {
  149. node.conversationId = parent.conversationId;
  150. changed = true;
  151. }
  152. }
  153. if (!changed) break;
  154. }
  155. for (const node of nodes) {
  156. if (!node.conversationId) {
  157. node.conversationId = `legacy:${node.id}`;
  158. }
  159. }
  160. }
  161. function migrateLegacyArray(tasks) {
  162. const nodes = tasks.map((item) => {
  163. const hasParent = Boolean(item.parentId);
  164. return normalizeNode(
  165. {
  166. id: item.id,
  167. conversationId: extractSessionKey(item.notes),
  168. parentId: item.parentId || null,
  169. nodeType: hasParent ? "thought" : "prompt",
  170. name: item.name,
  171. status: item.status,
  172. content: extractContentFromNotes(item.notes) || item.notes || "",
  173. timestamps: {
  174. created: item.createdAt,
  175. started: item.startTime,
  176. completed: item.endTime,
  177. },
  178. plannedApproach: item.plannedApproach,
  179. bugDetails: item.bugDetails,
  180. notes: item.notes,
  181. updatedAt: item.updatedAt,
  182. },
  183. {},
  184. );
  185. });
  186. topologicalConversationPropagation(nodes);
  187. const nodeSet = new Set(nodes.map((node) => node.id));
  188. const edges = [];
  189. const dedup = new Set();
  190. for (const node of nodes) {
  191. if (!node.parentId) continue;
  192. if (!nodeSet.has(node.parentId)) {
  193. node.parentId = null;
  194. continue;
  195. }
  196. const key = `${node.parentId}->${node.id}:hierarchy`;
  197. if (dedup.has(key)) continue;
  198. dedup.add(key);
  199. edges.push(
  200. normalizeEdge({
  201. from: node.parentId,
  202. to: node.id,
  203. type: "hierarchy",
  204. }),
  205. );
  206. }
  207. return {
  208. version: 2,
  209. updatedAt: nowIso(),
  210. nodes,
  211. edges,
  212. };
  213. }
  214. function normalizeStore(raw) {
  215. if (Array.isArray(raw)) {
  216. return migrateLegacyArray(raw);
  217. }
  218. const source = raw && typeof raw === "object" ? raw : {};
  219. const nodes = Array.isArray(source.nodes) ? source.nodes.map((item) => normalizeNode(item, item)) : [];
  220. const edgesRaw = Array.isArray(source.edges) ? source.edges : [];
  221. const nodeSet = new Set(nodes.map((node) => node.id));
  222. const edges = [];
  223. const dedup = new Set();
  224. for (const edge of edgesRaw) {
  225. const next = normalizeEdge(edge);
  226. if (!next.from || !next.to || next.from === next.to) continue;
  227. if (!nodeSet.has(next.from) || !nodeSet.has(next.to)) continue;
  228. const key = `${next.from}->${next.to}:${next.type}`;
  229. if (dedup.has(key)) continue;
  230. dedup.add(key);
  231. edges.push(next);
  232. }
  233. for (const node of nodes) {
  234. if (!node.parentId) continue;
  235. if (!nodeSet.has(node.parentId)) {
  236. node.parentId = null;
  237. continue;
  238. }
  239. const key = `${node.parentId}->${node.id}:hierarchy`;
  240. if (dedup.has(key)) continue;
  241. dedup.add(key);
  242. edges.push(
  243. normalizeEdge({
  244. from: node.parentId,
  245. to: node.id,
  246. type: "hierarchy",
  247. }),
  248. );
  249. }
  250. topologicalConversationPropagation(nodes);
  251. return {
  252. version: 2,
  253. updatedAt: normalizeIso(source.updatedAt, nowIso()),
  254. nodes,
  255. edges,
  256. };
  257. }
  258. function buildHierarchyMaps(store) {
  259. const children = new Map();
  260. const incomingHierarchy = new Map();
  261. for (const edge of store.edges) {
  262. if (edge.type !== "hierarchy") continue;
  263. if (!children.has(edge.from)) children.set(edge.from, []);
  264. children.get(edge.from).push(edge.to);
  265. incomingHierarchy.set(edge.to, edge.from);
  266. }
  267. return { children, incomingHierarchy };
  268. }
  269. class TaskRepository {
  270. constructor(options = {}) {
  271. this.filePath = options.filePath;
  272. this._mutationQueue = Promise.resolve();
  273. }
  274. async ensureFile() {
  275. const dir = path.dirname(this.filePath);
  276. await fsp.mkdir(dir, { recursive: true });
  277. if (!fs.existsSync(this.filePath)) {
  278. const initial = normalizeStore({ version: 2, nodes: [], edges: [] });
  279. await this.writeStore(initial);
  280. }
  281. }
  282. async readStore() {
  283. await this.ensureFile();
  284. try {
  285. const raw = await fsp.readFile(this.filePath, "utf8");
  286. const parsed = JSON.parse(raw);
  287. const normalized = normalizeStore(parsed);
  288. return normalized;
  289. } catch {
  290. return normalizeStore({ version: 2, nodes: [], edges: [] });
  291. }
  292. }
  293. async writeStore(store) {
  294. const normalized = normalizeStore(store);
  295. normalized.updatedAt = nowIso();
  296. const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
  297. await fsp.writeFile(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
  298. await fsp.rename(tmpPath, this.filePath);
  299. return normalized;
  300. }
  301. async mutate(mutator) {
  302. this._mutationQueue = this._mutationQueue.then(async () => {
  303. const store = await this.readStore();
  304. const result = await mutator(store);
  305. const nextStore = await this.writeStore(store);
  306. return { result, store: nextStore };
  307. });
  308. return this._mutationQueue;
  309. }
  310. async listConversations() {
  311. const store = await this.readStore();
  312. const { incomingHierarchy } = buildHierarchyMaps(store);
  313. const groups = new Map();
  314. for (const node of store.nodes) {
  315. if (!groups.has(node.conversationId)) {
  316. groups.set(node.conversationId, []);
  317. }
  318. groups.get(node.conversationId).push(node);
  319. }
  320. const summaries = [];
  321. for (const [conversationId, nodes] of groups.entries()) {
  322. const nodeIds = new Set(nodes.map((node) => node.id));
  323. const roots = nodes.filter((node) => !incomingHierarchy.has(node.id) || !nodeIds.has(incomingHierarchy.get(node.id)));
  324. const titleRoot = roots
  325. .slice()
  326. .sort((a, b) => (Date.parse(a.timestamps.created) || 0) - (Date.parse(b.timestamps.created) || 0))[0];
  327. const updatedAt = nodes.reduce((max, node) => {
  328. const t = Date.parse(node.updatedAt || node.timestamps.created || "") || 0;
  329. return Math.max(max, t);
  330. }, 0);
  331. const runningCount = nodes.filter((node) => node.status === "in-progress").length;
  332. const bugCount = nodes.filter((node) => node.status === "bug").length;
  333. summaries.push({
  334. conversationId,
  335. title: titleRoot ? titleRoot.name : conversationId,
  336. updatedAt: updatedAt ? new Date(updatedAt).toISOString() : nowIso(),
  337. nodeCount: nodes.length,
  338. runningCount,
  339. bugCount,
  340. });
  341. }
  342. summaries.sort((a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0));
  343. return summaries;
  344. }
  345. async getConversationGraph(conversationId) {
  346. const store = await this.readStore();
  347. const nodes = store.nodes.filter((node) => node.conversationId === conversationId);
  348. const nodeSet = new Set(nodes.map((node) => node.id));
  349. const edges = store.edges.filter((edge) => nodeSet.has(edge.from) && nodeSet.has(edge.to));
  350. return {
  351. conversationId,
  352. nodes,
  353. edges,
  354. summary: {
  355. nodeCount: nodes.length,
  356. edgeCount: edges.length,
  357. runningCount: nodes.filter((node) => node.status === "in-progress").length,
  358. bugCount: nodes.filter((node) => node.status === "bug").length,
  359. },
  360. };
  361. }
  362. async getNodeById(id) {
  363. const store = await this.readStore();
  364. return store.nodes.find((node) => node.id === id) || null;
  365. }
  366. async upsertNode(input) {
  367. return this.mutate(async (store) => {
  368. const index = store.nodes.findIndex((node) => node.id === input.id);
  369. const existing = index >= 0 ? store.nodes[index] : null;
  370. const merged = normalizeNode(input, existing || {});
  371. const byId = new Map(store.nodes.map((node) => [node.id, node]));
  372. if (existing && merged.id !== existing.id) {
  373. throw new Error("不允许变更节点 ID");
  374. }
  375. if (merged.parentId && merged.parentId === merged.id) {
  376. throw new Error("父节点不能是自己");
  377. }
  378. if (merged.parentId && !byId.has(merged.parentId)) {
  379. throw new Error("父节点不存在");
  380. }
  381. if (!merged.conversationId) {
  382. if (merged.parentId && byId.get(merged.parentId)) {
  383. merged.conversationId = byId.get(merged.parentId).conversationId;
  384. } else {
  385. merged.conversationId = `legacy:${merged.id}`;
  386. }
  387. }
  388. merged.updatedAt = nowIso();
  389. if (index >= 0) {
  390. store.nodes[index] = merged;
  391. } else {
  392. store.nodes.push(merged);
  393. }
  394. const nodeSet = new Set(store.nodes.map((node) => node.id));
  395. store.edges = store.edges.filter((edge) => nodeSet.has(edge.from) && nodeSet.has(edge.to));
  396. store.edges = store.edges.filter((edge) => !(edge.type === "hierarchy" && edge.to === merged.id));
  397. if (merged.parentId) {
  398. store.edges.push(
  399. normalizeEdge({
  400. from: merged.parentId,
  401. to: merged.id,
  402. type: "hierarchy",
  403. }),
  404. );
  405. }
  406. return merged;
  407. });
  408. }
  409. async deleteNode(id) {
  410. return this.mutate(async (store) => {
  411. const nodeIds = new Set(store.nodes.map((node) => node.id));
  412. if (!nodeIds.has(id)) {
  413. throw new Error("节点不存在");
  414. }
  415. const children = new Map();
  416. for (const edge of store.edges) {
  417. if (edge.type !== "hierarchy") continue;
  418. if (!children.has(edge.from)) children.set(edge.from, []);
  419. children.get(edge.from).push(edge.to);
  420. }
  421. const deleting = new Set([id]);
  422. const stack = [id];
  423. while (stack.length) {
  424. const current = stack.pop();
  425. const nextChildren = children.get(current) || [];
  426. for (const childId of nextChildren) {
  427. if (deleting.has(childId)) continue;
  428. deleting.add(childId);
  429. stack.push(childId);
  430. }
  431. }
  432. store.nodes = store.nodes.filter((node) => !deleting.has(node.id));
  433. store.edges = store.edges.filter((edge) => !deleting.has(edge.from) && !deleting.has(edge.to));
  434. return { deletedCount: deleting.size };
  435. });
  436. }
  437. async upsertEdge(input) {
  438. return this.mutate(async (store) => {
  439. const edge = normalizeEdge(input);
  440. if (!edge.from || !edge.to) {
  441. throw new Error("边的 from/to 不能为空");
  442. }
  443. if (edge.from === edge.to) {
  444. throw new Error("边不能自环");
  445. }
  446. const nodeSet = new Set(store.nodes.map((node) => node.id));
  447. if (!nodeSet.has(edge.from) || !nodeSet.has(edge.to)) {
  448. throw new Error("边关联的节点不存在");
  449. }
  450. const exists = store.edges.find((item) => item.from === edge.from && item.to === edge.to && item.type === edge.type);
  451. if (exists) return exists;
  452. store.edges.push(edge);
  453. return edge;
  454. });
  455. }
  456. async deleteEdge(params) {
  457. return this.mutate(async (store) => {
  458. const from = toStringSafe(params.from, 180);
  459. const to = toStringSafe(params.to, 180);
  460. const type = params.type ? toStringSafe(params.type, 48) : null;
  461. const before = store.edges.length;
  462. store.edges = store.edges.filter((edge) => {
  463. if (edge.from !== from || edge.to !== to) return true;
  464. if (type && edge.type !== type) return true;
  465. return false;
  466. });
  467. return { deletedCount: before - store.edges.length };
  468. });
  469. }
  470. }
  471. module.exports = {
  472. TaskRepository,
  473. STATUS_SET,
  474. NODE_TYPE_SET,
  475. EDGE_TYPE_SET,
  476. createId,
  477. };