@fgbg/mdocs 1.3.22 → 1.3.24

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.
Files changed (104) hide show
  1. package/README.md +1 -1
  2. package/agent-skills/changelog/SKILL.md +13 -0
  3. package/agent-skills/getting-started-installation/SKILL.md +8 -2
  4. package/dist/server/agent/Agent/run-pi-agent.js +7 -0
  5. package/dist/server/agent/Agent/run-pi-agent.js.map +1 -1
  6. package/dist/server/agent/Agent/session-manager.js +354 -144
  7. package/dist/server/agent/Agent/session-manager.js.map +1 -1
  8. package/dist/server/agent/Agent/session-manager.queue.test.js +65 -0
  9. package/dist/server/agent/Agent/session-manager.queue.test.js.map +1 -0
  10. package/dist/server/agent/Agent/session-meta-lock.js +73 -0
  11. package/dist/server/agent/Agent/session-meta-lock.js.map +1 -0
  12. package/dist/server/agent/Agent/session-meta-lock.test.js +44 -0
  13. package/dist/server/agent/Agent/session-meta-lock.test.js.map +1 -0
  14. package/dist/server/agent/Agent/session-repair.test.js +40 -0
  15. package/dist/server/agent/Agent/session-repair.test.js.map +1 -0
  16. package/dist/server/agent/Agent/system-prompt.js +3 -3
  17. package/dist/server/agent/Agent/tool-ui-effects.js +41 -0
  18. package/dist/server/agent/Agent/tool-ui-effects.js.map +1 -1
  19. package/dist/server/agent/Agent/tools-documents.js +62 -12
  20. package/dist/server/agent/Agent/tools-documents.js.map +1 -1
  21. package/dist/server/agent/Agent/tools-overwrite.js +1 -1
  22. package/dist/server/agent/Agent/tools-overwrite.js.map +1 -1
  23. package/dist/server/agent/Agent/tools-registry.test.js +16 -0
  24. package/dist/server/agent/Agent/tools-registry.test.js.map +1 -1
  25. package/dist/server/agent/Agent/tools-sheet.js +918 -0
  26. package/dist/server/agent/Agent/tools-sheet.js.map +1 -0
  27. package/dist/server/agent/Agent/tools-sheet.test.js +202 -0
  28. package/dist/server/agent/Agent/tools-sheet.test.js.map +1 -0
  29. package/dist/server/agent/Agent/tools-template.js +4 -1
  30. package/dist/server/agent/Agent/tools-template.js.map +1 -1
  31. package/dist/server/cli/main.js +81 -4
  32. package/dist/server/cli/main.js.map +1 -1
  33. package/dist/server/cli/self-update.js +49 -20
  34. package/dist/server/cli/self-update.js.map +1 -1
  35. package/dist/server/cli/self-update.test.js +21 -2
  36. package/dist/server/cli/self-update.test.js.map +1 -1
  37. package/dist/server/config/index.js +2 -1
  38. package/dist/server/config/index.js.map +1 -1
  39. package/dist/server/documents/document.service.integration.test.js +67 -1
  40. package/dist/server/documents/document.service.integration.test.js.map +1 -1
  41. package/dist/server/documents/document.service.js +80 -5
  42. package/dist/server/documents/document.service.js.map +1 -1
  43. package/dist/server/main.js +6 -0
  44. package/dist/server/main.js.map +1 -1
  45. package/dist/server/routes/documents.routes.js +6 -2
  46. package/dist/server/routes/documents.routes.js.map +1 -1
  47. package/dist/server/search/embedding-model.js +33 -11
  48. package/dist/server/search/embedding-model.js.map +1 -1
  49. package/dist/shared/docPath.js +5 -2
  50. package/dist/shared/docPath.js.map +1 -1
  51. package/dist/shared/file-type-policy.js +15 -0
  52. package/dist/shared/file-type-policy.js.map +1 -1
  53. package/dist/shared/file-type-policy.test.js +17 -1
  54. package/dist/shared/file-type-policy.test.js.map +1 -1
  55. package/dist/shared/file-types.js +2 -0
  56. package/dist/shared/file-types.js.map +1 -1
  57. package/dist/shared/sheetDocument.js +781 -0
  58. package/dist/shared/sheetDocument.js.map +1 -0
  59. package/dist/shared/sheetDocument.test.js +179 -0
  60. package/dist/shared/sheetDocument.test.js.map +1 -0
  61. package/dist/shared/storagePath.js +15 -8
  62. package/dist/shared/storagePath.js.map +1 -1
  63. package/dist/web/assets/AgentChatPanel-NcsMAhWM.js +22 -0
  64. package/dist/web/assets/{AgentSkillFormCard-CzJG8FK4.js → AgentSkillFormCard-B99CncJO.js} +3 -3
  65. package/dist/web/assets/{AiWriteWorkbench-DiSDDAee.js → AiWriteWorkbench-DomEbwzU.js} +6 -6
  66. package/dist/web/assets/{DiagramEditor-NtNY3sZL-DfkpQX3P.js → DiagramEditor-NtNY3sZL-BhsjOCtK.js} +1 -1
  67. package/dist/web/assets/DocChrome-wfHrPEB7.js +11 -0
  68. package/dist/web/assets/DocumentEditor-CCuqHlmF.js +1 -0
  69. package/dist/web/assets/HtmlEditor-g2NNYmv2.js +1 -0
  70. package/dist/web/assets/{MergeView-CbFSN3Bi.js → MergeView-GL7xdFM4.js} +4 -4
  71. package/dist/web/assets/PlaygroundPage-Bb4X1bvp.js +8 -0
  72. package/dist/web/assets/SettingsPage-B4INgMK-.js +1 -0
  73. package/dist/web/assets/SheetEditor-Cka57s62.js +16 -0
  74. package/dist/web/assets/SheetEditor-gXD-sRv2.css +1 -0
  75. package/dist/web/assets/{Toolbar-D9FcRwg3.js → Toolbar-Bk3LKZfg.js} +19 -29
  76. package/dist/web/assets/VisitorPickerModal-zafJ6beK.js +1 -0
  77. package/dist/web/assets/{check-Bf8YbzH7.js → check-BMeXlsHC.js} +1 -1
  78. package/dist/web/assets/{copy-SBghRY2T.js → copy-CCsiKd_j.js} +1 -1
  79. package/dist/web/assets/{diagram-CgEd0hrk.js → diagram-BF8g9yg4.js} +3 -3
  80. package/dist/web/assets/docPlainCharCount-BjBZaoHy.js +11 -0
  81. package/dist/web/assets/{editor-CL03se2B.js → editor-DBWWidi3.js} +1 -1
  82. package/dist/web/assets/file-type-policy-C6Nun42Q.js +1 -0
  83. package/dist/web/assets/index-3B7_9QOQ.js +299 -0
  84. package/dist/web/assets/index-BF754bDB.css +1 -0
  85. package/dist/web/assets/italic-B6bGjo1_.js +11 -0
  86. package/dist/web/assets/{meta2d-1-g23JOU.js → meta2d-DP4Vt_ZE.js} +1 -1
  87. package/dist/web/assets/{meta2dEngine-BD2XbzaV-ulfRZEvk.js → meta2dEngine-BD2XbzaV-9HAADQ7Y.js} +1 -1
  88. package/dist/web/assets/{motion-Dyy8pB0Q.js → motion-BAXVQJfN.js} +1 -1
  89. package/dist/web/assets/{plus-CEapsZwl.js → plus-BD44y7Zf.js} +1 -1
  90. package/dist/web/assets/{react-BkX5Koku.js → react-2y9P8xJn.js} +171 -168
  91. package/dist/web/assets/{react-vendor-BuABiQkA.js → react-vendor-BVS1XoFi.js} +1 -1
  92. package/dist/web/assets/{ui-ZPt_BNBg.js → ui-CEFkTVnG.js} +200 -181
  93. package/dist/web/index.html +6 -6
  94. package/package.json +30 -21
  95. package/dist/web/assets/AgentChatPanel-BZlAgyrV.js +0 -22
  96. package/dist/web/assets/DocChrome-QTGER_Af.js +0 -21
  97. package/dist/web/assets/DocumentEditor-DYSRZnJa.js +0 -1
  98. package/dist/web/assets/HtmlEditor-oDPrhq3o.js +0 -1
  99. package/dist/web/assets/PlaygroundPage-DuMH_V-7.js +0 -8
  100. package/dist/web/assets/SettingsPage-C_RMO-5M.js +0 -1
  101. package/dist/web/assets/VisitorPickerModal-CM13Y6jQ.js +0 -1
  102. package/dist/web/assets/file-type-policy-GUk904tm.js +0 -1
  103. package/dist/web/assets/index-CIgmAX2Q.css +0 -1
  104. package/dist/web/assets/index-Cwy87sZC.js +0 -293
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { getConfig } from "../../config/index.js";
5
+ import { atomicWriteJson, withSessionMetaLock } from "./session-meta-lock.js";
5
6
  const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
7
  /** 空白帮写(无 documentId)的 lastOpened 键 */
7
8
  export const CODING_BLANK_DOC_KEY = "__blank__";
@@ -96,7 +97,164 @@ export class AgentSessionManager {
96
97
  }
97
98
  }
98
99
  async writeMeta(meta) {
99
- await fs.promises.writeFile(this.metaPath, JSON.stringify(meta, null, 2), "utf8");
100
+ await atomicWriteJson(this.metaPath, meta);
101
+ }
102
+ /**
103
+ * 从目录内 *.jsonl 重建 sessions 列表(不删文件)。
104
+ * 用于 meta 损坏 / 被写成只剩一条时的恢复。
105
+ */
106
+ async scanTranscriptEntries() {
107
+ await this.ensureSessionDir();
108
+ let names = [];
109
+ try {
110
+ names = await fs.promises.readdir(this.sessionDir);
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ const out = [];
116
+ for (const name of names) {
117
+ if (!name.endsWith(".jsonl"))
118
+ continue;
119
+ const id = name.slice(0, -".jsonl".length);
120
+ if (!isSessionId(id))
121
+ continue;
122
+ const full = this.jsonlPath(id);
123
+ let updatedAt = nowIso();
124
+ try {
125
+ const st = await fs.promises.stat(full);
126
+ updatedAt = st.mtime.toISOString();
127
+ }
128
+ catch {
129
+ /* ignore */
130
+ }
131
+ const title = await this.peekTitleFromTranscript(id);
132
+ out.push({ id, title, updatedAt });
133
+ }
134
+ return out;
135
+ }
136
+ async peekTitleFromTranscript(sessionId) {
137
+ try {
138
+ const txt = await fs.promises.readFile(this.jsonlPath(sessionId), "utf8");
139
+ for (const line of txt.split("\n")) {
140
+ const t = line.trim();
141
+ if (!t)
142
+ continue;
143
+ try {
144
+ const row = JSON.parse(t);
145
+ if (row.role === "user" && typeof row.content === "string" && row.content.trim()) {
146
+ return titleFromUserContent(row.content);
147
+ }
148
+ }
149
+ catch {
150
+ /* ignore */
151
+ }
152
+ }
153
+ }
154
+ catch {
155
+ /* ignore */
156
+ }
157
+ return undefined;
158
+ }
159
+ /** readMeta 失败或 sessions 空时,用磁盘 jsonl 拼一份可用 meta(不写盘)。 */
160
+ async recoverMetaFromDisk(preferLastOpened) {
161
+ const scanned = await this.scanTranscriptEntries();
162
+ if (scanned.length === 0)
163
+ return null;
164
+ const prefer = preferLastOpened && isSessionId(preferLastOpened) && scanned.some((s) => s.id === preferLastOpened)
165
+ ? preferLastOpened
166
+ : scanned.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))[0].id;
167
+ return {
168
+ lastOpenedSessionId: prefer,
169
+ sessions: scanned,
170
+ lastOpenedByDocument: {},
171
+ };
172
+ }
173
+ /**
174
+ * Manager 公开方法级串行入口(队列 key = metaPath)。
175
+ * 所有会动磁盘会话状态的路径只应通过这里,禁止队外裸写。
176
+ */
177
+ runSerialized(fn) {
178
+ return withSessionMetaLock(this.metaPath, fn);
179
+ }
180
+ /**
181
+ * 已在 runSerialized 内:读改写 meta。
182
+ * read 失败时先从 jsonl 恢复,避免再写成「只剩当前一条」。
183
+ */
184
+ async mutateMetaUnlocked(mutator) {
185
+ let meta = await this.readMeta();
186
+ if (!meta) {
187
+ meta =
188
+ (await this.recoverMetaFromDisk()) ?? {
189
+ lastOpenedSessionId: "",
190
+ sessions: [],
191
+ lastOpenedByDocument: {},
192
+ };
193
+ }
194
+ await mutator(meta);
195
+ if (!meta.lastOpenedSessionId || !isSessionId(meta.lastOpenedSessionId)) {
196
+ const fallback = meta.sessions[0]?.id ?? randomUUID();
197
+ meta.lastOpenedSessionId = fallback;
198
+ this.ensureSessionEntry(meta, fallback, { touchUpdatedAt: false });
199
+ }
200
+ await this.writeMeta(meta);
201
+ return meta;
202
+ }
203
+ /**
204
+ * 把磁盘上有 jsonl、但 meta.sessions 里没有的条目补回去。
205
+ * @returns 补入条数
206
+ */
207
+ async repairMetaFromDisk(opts) {
208
+ const dryRun = opts?.dryRun === true;
209
+ await this.ensureSessionDir();
210
+ return this.runSerialized(async () => {
211
+ const existing = await this.readMeta();
212
+ const scanned = await this.scanTranscriptEntries();
213
+ const byId = new Map();
214
+ for (const s of existing?.sessions ?? [])
215
+ byId.set(s.id, { ...s });
216
+ let added = 0;
217
+ for (const s of scanned) {
218
+ if (byId.has(s.id)) {
219
+ const cur = byId.get(s.id);
220
+ if (!cur.title?.trim() && s.title)
221
+ cur.title = s.title;
222
+ if (!cur.updatedAt && s.updatedAt)
223
+ cur.updatedAt = s.updatedAt;
224
+ continue;
225
+ }
226
+ byId.set(s.id, s);
227
+ added += 1;
228
+ }
229
+ const sessions = Array.from(byId.values());
230
+ const lastOpened = existing?.lastOpenedSessionId && byId.has(existing.lastOpenedSessionId)
231
+ ? existing.lastOpenedSessionId
232
+ : sessions.slice().sort((a, b) => ((a.updatedAt ?? "") < (b.updatedAt ?? "") ? 1 : -1))[0]
233
+ ?.id ?? randomUUID();
234
+ if (sessions.length === 0) {
235
+ return {
236
+ before: existing?.sessions.length ?? 0,
237
+ after: 0,
238
+ added: 0,
239
+ sessionDir: this.sessionDir,
240
+ dryRun,
241
+ };
242
+ }
243
+ const next = {
244
+ lastOpenedSessionId: lastOpened,
245
+ sessions,
246
+ lastOpenedByDocument: existing?.lastOpenedByDocument,
247
+ };
248
+ if (!dryRun)
249
+ await this.writeMeta(next);
250
+ return {
251
+ before: existing?.sessions.length ?? 0,
252
+ after: sessions.length,
253
+ added,
254
+ sessionDir: this.sessionDir,
255
+ dryRun,
256
+ };
257
+ });
100
258
  }
101
259
  ensureSessionEntry(meta, sessionId, patch) {
102
260
  const idx = meta.sessions.findIndex((s) => s.id === sessionId);
@@ -175,134 +333,130 @@ export class AgentSessionManager {
175
333
  return [];
176
334
  }
177
335
  }
178
- /**
179
- * 确保有可用 lastOpened。
180
- * coding + documentId:按文档维度;normal:全局 lastOpened。
181
- */
182
- async ensureLastOpened(documentId) {
183
- await this.ensureSessionDir();
336
+ /** 已在 runSerialized 内:确保 lastOpened。 */
337
+ async ensureLastOpenedUnlocked(documentId) {
184
338
  if (this.kind !== "coding") {
185
- if (this.cachedSessionId)
186
- return this.cachedSessionId;
187
- const meta = await this.readMeta();
188
- if (!meta) {
189
- const sessionId = randomUUID();
190
- await this.writeMeta({
191
- lastOpenedSessionId: sessionId,
192
- sessions: [{ id: sessionId, updatedAt: nowIso() }],
193
- });
194
- this.cachedSessionId = sessionId;
195
- return sessionId;
196
- }
197
- this.ensureSessionEntry(meta, meta.lastOpenedSessionId, { touchUpdatedAt: false });
198
- await this.writeMeta(meta);
339
+ const meta = await this.mutateMetaUnlocked((m) => {
340
+ if (!m.lastOpenedSessionId || !isSessionId(m.lastOpenedSessionId)) {
341
+ const sessionId = m.sessions[0]?.id ?? randomUUID();
342
+ m.lastOpenedSessionId = sessionId;
343
+ this.ensureSessionEntry(m, sessionId, { touchUpdatedAt: false });
344
+ return;
345
+ }
346
+ this.ensureSessionEntry(m, m.lastOpenedSessionId, { touchUpdatedAt: false });
347
+ });
199
348
  this.cachedSessionId = meta.lastOpenedSessionId;
200
349
  return meta.lastOpenedSessionId;
201
350
  }
202
351
  const key = docScopeKey(documentId);
203
- const meta = (await this.readMeta()) ?? {
204
- lastOpenedSessionId: "",
205
- sessions: [],
206
- lastOpenedByDocument: {},
207
- };
208
- const scoped = this.filterByDocument(meta.sessions, documentId);
209
- const mapped = meta.lastOpenedByDocument?.[key];
210
- const mappedOk = mapped &&
211
- isSessionId(mapped) &&
212
- scoped.some((s) => s.id === mapped);
213
- if (mappedOk) {
214
- meta.lastOpenedSessionId = mapped;
215
- this.ensureSessionEntry(meta, mapped, { touchUpdatedAt: false });
216
- await this.writeMeta(meta);
217
- this.cachedSessionId = mapped;
218
- return mapped;
219
- }
220
- if (scoped[0]) {
221
- const sessionId = scoped[0].id;
222
- meta.lastOpenedSessionId = sessionId;
223
- this.setLastOpenedForDoc(meta, documentId, sessionId);
224
- await this.writeMeta(meta);
225
- this.cachedSessionId = sessionId;
226
- return sessionId;
227
- }
228
- const sessionId = randomUUID();
229
- meta.lastOpenedSessionId = sessionId;
230
- this.ensureSessionEntry(meta, sessionId, {
231
- touchUpdatedAt: true,
232
- documentId: documentId?.trim() ? documentId.trim() : null,
352
+ const meta = await this.mutateMetaUnlocked((m) => {
353
+ const scoped = this.filterByDocument(m.sessions, documentId);
354
+ const mapped = m.lastOpenedByDocument?.[key];
355
+ const mappedOk = mapped && isSessionId(mapped) && scoped.some((s) => s.id === mapped);
356
+ if (mappedOk) {
357
+ m.lastOpenedSessionId = mapped;
358
+ this.ensureSessionEntry(m, mapped, { touchUpdatedAt: false });
359
+ return;
360
+ }
361
+ if (scoped[0]) {
362
+ const sessionId = scoped[0].id;
363
+ m.lastOpenedSessionId = sessionId;
364
+ this.setLastOpenedForDoc(m, documentId, sessionId);
365
+ return;
366
+ }
367
+ const sessionId = randomUUID();
368
+ m.lastOpenedSessionId = sessionId;
369
+ this.ensureSessionEntry(m, sessionId, {
370
+ touchUpdatedAt: true,
371
+ documentId: documentId?.trim() ? documentId.trim() : null,
372
+ });
373
+ this.setLastOpenedForDoc(m, documentId, sessionId);
233
374
  });
234
- this.setLastOpenedForDoc(meta, documentId, sessionId);
235
- await this.writeMeta(meta);
236
- this.cachedSessionId = sessionId;
237
- return sessionId;
375
+ this.cachedSessionId = meta.lastOpenedSessionId;
376
+ return meta.lastOpenedSessionId;
377
+ }
378
+ /**
379
+ * 确保有可用 lastOpened。
380
+ * coding + documentId:按文档维度;normal:全局 lastOpened。
381
+ */
382
+ async ensureLastOpened(documentId) {
383
+ await this.ensureSessionDir();
384
+ if (this.kind !== "coding" && this.cachedSessionId)
385
+ return this.cachedSessionId;
386
+ return this.runSerialized(() => this.ensureLastOpenedUnlocked(documentId));
238
387
  }
239
388
  async listSessions(documentId) {
240
- const lastOpenedSessionId = await this.ensureLastOpened(documentId);
241
- const meta = (await this.readMeta());
242
- const scoped = this.filterByDocument(meta.sessions, documentId);
243
- return {
244
- lastOpenedSessionId,
245
- sessions: this.sortSummaries(scoped),
246
- };
389
+ await this.ensureSessionDir();
390
+ return this.runSerialized(async () => {
391
+ const lastOpenedSessionId = await this.ensureLastOpenedUnlocked(documentId);
392
+ const meta = await this.readMeta();
393
+ const scoped = this.filterByDocument(meta?.sessions ?? [], documentId);
394
+ return {
395
+ lastOpenedSessionId,
396
+ sessions: this.sortSummaries(scoped),
397
+ };
398
+ });
247
399
  }
248
400
  async loadLastOpenedMessages(documentId) {
249
- const sessionId = await this.ensureLastOpened(documentId);
250
- return { sessionId, messages: await this.loadTranscript(sessionId) };
401
+ await this.ensureSessionDir();
402
+ return this.runSerialized(async () => {
403
+ const sessionId = await this.ensureLastOpenedUnlocked(documentId);
404
+ return { sessionId, messages: await this.loadTranscript(sessionId) };
405
+ });
251
406
  }
252
407
  /** 新建空会话并设为该文档(或空白桶)的 lastOpened */
253
408
  async createSession(documentId) {
254
409
  await this.ensureSessionDir();
255
410
  const sessionId = randomUUID();
256
- const meta = (await this.readMeta()) ?? {
257
- lastOpenedSessionId: sessionId,
258
- sessions: [],
259
- lastOpenedByDocument: {},
260
- };
261
- meta.lastOpenedSessionId = sessionId;
262
411
  const doc = this.kind === "coding"
263
412
  ? documentId?.trim()
264
413
  ? documentId.trim()
265
414
  : null
266
415
  : undefined;
267
- this.ensureSessionEntry(meta, sessionId, {
268
- title: "新会话",
269
- touchUpdatedAt: true,
270
- documentId: doc,
416
+ return this.runSerialized(async () => {
417
+ await this.mutateMetaUnlocked((meta) => {
418
+ meta.lastOpenedSessionId = sessionId;
419
+ this.ensureSessionEntry(meta, sessionId, {
420
+ title: "新会话",
421
+ touchUpdatedAt: true,
422
+ documentId: doc,
423
+ });
424
+ if (this.kind === "coding") {
425
+ this.setLastOpenedForDoc(meta, documentId, sessionId);
426
+ }
427
+ });
428
+ this.cachedSessionId = sessionId;
429
+ return { sessionId, messages: [] };
271
430
  });
272
- if (this.kind === "coding") {
273
- this.setLastOpenedForDoc(meta, documentId, sessionId);
274
- }
275
- await this.writeMeta(meta);
276
- this.cachedSessionId = sessionId;
277
- return { sessionId, messages: [] };
278
431
  }
279
432
  /** 切换 lastOpened;coding 时可校验属于同一 documentId */
280
433
  async openSession(sessionId, documentId) {
281
434
  if (!isSessionId(sessionId))
282
435
  throw new Error("invalid_session_id");
283
436
  await this.ensureSessionDir();
284
- const meta = await this.readMeta();
285
- const inMeta = Boolean(meta?.sessions.some((s) => s.id === sessionId));
286
- const hasFile = fs.existsSync(this.jsonlPath(sessionId));
287
- if (!inMeta && !hasFile)
288
- throw new Error("session_not_found");
289
- const next = meta ?? { lastOpenedSessionId: sessionId, sessions: [], lastOpenedByDocument: {} };
290
- const entry = next.sessions.find((s) => s.id === sessionId);
291
- if (this.kind === "coding" && documentId !== undefined) {
292
- const want = docScopeKey(documentId);
293
- const have = entry ? entryDocKey(entry) : CODING_BLANK_DOC_KEY;
294
- if (want !== have)
295
- throw new Error("session_document_mismatch");
296
- }
297
- next.lastOpenedSessionId = sessionId;
298
- this.ensureSessionEntry(next, sessionId, { touchUpdatedAt: true });
299
- if (this.kind === "coding") {
300
- const bindDoc = entry?.documentId ?? (documentId !== undefined ? documentId : null);
301
- this.setLastOpenedForDoc(next, bindDoc, sessionId);
302
- }
303
- await this.writeMeta(next);
304
- this.cachedSessionId = sessionId;
305
- return { sessionId, messages: await this.loadTranscript(sessionId) };
437
+ return this.runSerialized(async () => {
438
+ await this.mutateMetaUnlocked((meta) => {
439
+ const inMeta = meta.sessions.some((s) => s.id === sessionId);
440
+ const hasFile = fs.existsSync(this.jsonlPath(sessionId));
441
+ if (!inMeta && !hasFile)
442
+ throw new Error("session_not_found");
443
+ const entry = meta.sessions.find((s) => s.id === sessionId);
444
+ if (this.kind === "coding" && documentId !== undefined) {
445
+ const want = docScopeKey(documentId);
446
+ const have = entry ? entryDocKey(entry) : CODING_BLANK_DOC_KEY;
447
+ if (want !== have)
448
+ throw new Error("session_document_mismatch");
449
+ }
450
+ meta.lastOpenedSessionId = sessionId;
451
+ this.ensureSessionEntry(meta, sessionId, { touchUpdatedAt: true });
452
+ if (this.kind === "coding") {
453
+ const bindDoc = entry?.documentId ?? (documentId !== undefined ? documentId : null);
454
+ this.setLastOpenedForDoc(meta, bindDoc, sessionId);
455
+ }
456
+ });
457
+ this.cachedSessionId = sessionId;
458
+ return { sessionId, messages: await this.loadTranscript(sessionId) };
459
+ });
306
460
  }
307
461
  /**
308
462
  * 空白帮写写回成文后:把 session 绑到新 documentId,并设为该文 lastOpened。
@@ -316,49 +470,43 @@ export class AgentSessionManager {
316
470
  if (!doc)
317
471
  throw new Error("document_id_required");
318
472
  await this.ensureSessionDir();
319
- const meta = (await this.readMeta()) ?? {
320
- lastOpenedSessionId: sessionId,
321
- sessions: [],
322
- lastOpenedByDocument: {},
323
- };
324
- const inMeta = meta.sessions.some((s) => s.id === sessionId);
325
- const hasFile = fs.existsSync(this.jsonlPath(sessionId));
326
- if (!inMeta && !hasFile)
327
- throw new Error("session_not_found");
328
- this.ensureSessionEntry(meta, sessionId, {
329
- documentId: doc,
330
- touchUpdatedAt: true,
473
+ return this.runSerialized(async () => {
474
+ await this.mutateMetaUnlocked((meta) => {
475
+ const inMeta = meta.sessions.some((s) => s.id === sessionId);
476
+ const hasFile = fs.existsSync(this.jsonlPath(sessionId));
477
+ if (!inMeta && !hasFile)
478
+ throw new Error("session_not_found");
479
+ this.ensureSessionEntry(meta, sessionId, {
480
+ documentId: doc,
481
+ touchUpdatedAt: true,
482
+ });
483
+ meta.lastOpenedSessionId = sessionId;
484
+ this.setLastOpenedForDoc(meta, doc, sessionId);
485
+ if (meta.lastOpenedByDocument?.[CODING_BLANK_DOC_KEY] === sessionId) {
486
+ delete meta.lastOpenedByDocument[CODING_BLANK_DOC_KEY];
487
+ }
488
+ });
489
+ this.cachedSessionId = sessionId;
490
+ return { sessionId, documentId: doc };
331
491
  });
332
- meta.lastOpenedSessionId = sessionId;
333
- this.setLastOpenedForDoc(meta, doc, sessionId);
334
- // 从空白桶 lastOpened 清掉自己(若仍指向本 session)
335
- if (meta.lastOpenedByDocument?.[CODING_BLANK_DOC_KEY] === sessionId) {
336
- delete meta.lastOpenedByDocument[CODING_BLANK_DOC_KEY];
337
- }
338
- await this.writeMeta(meta);
339
- this.cachedSessionId = sessionId;
340
- return { sessionId, documentId: doc };
341
492
  }
342
- async touchLastOpened(sessionId, patch) {
343
- const meta = (await this.readMeta()) ?? {
344
- lastOpenedSessionId: sessionId,
345
- sessions: [],
346
- lastOpenedByDocument: {},
347
- };
348
- meta.lastOpenedSessionId = sessionId;
349
- const existing = meta.sessions.find((s) => s.id === sessionId);
350
- const needTitle = patch?.titleIfEmpty &&
351
- !(existing?.title && existing.title.trim() && existing.title !== "新会话");
352
- this.ensureSessionEntry(meta, sessionId, {
353
- title: needTitle ? patch.titleIfEmpty : undefined,
354
- touchUpdatedAt: true,
493
+ async touchLastOpenedUnlocked(sessionId, patch) {
494
+ await this.mutateMetaUnlocked((meta) => {
495
+ meta.lastOpenedSessionId = sessionId;
496
+ const existing = meta.sessions.find((s) => s.id === sessionId);
497
+ const needTitle = patch?.titleIfEmpty &&
498
+ !(existing?.title && existing.title.trim() && existing.title !== "新会话");
499
+ this.ensureSessionEntry(meta, sessionId, {
500
+ title: needTitle ? patch.titleIfEmpty : undefined,
501
+ touchUpdatedAt: true,
502
+ });
503
+ if (this.kind === "coding") {
504
+ this.setLastOpenedForDoc(meta, existing?.documentId ?? null, sessionId);
505
+ }
355
506
  });
356
- if (this.kind === "coding") {
357
- this.setLastOpenedForDoc(meta, existing?.documentId ?? null, sessionId);
358
- }
359
- await this.writeMeta(meta);
360
507
  this.cachedSessionId = sessionId;
361
508
  }
509
+ /** jsonl append + touch meta 同一 Manager 队列任务。 */
362
510
  async appendUser(sessionId, content, skillNames) {
363
511
  if (!isSessionId(sessionId))
364
512
  throw new Error("invalid_session_id");
@@ -370,14 +518,20 @@ export class AgentSessionManager {
370
518
  const row = { role: "user", content, ts: nowIso() };
371
519
  if (names.length > 0)
372
520
  row.skillNames = names;
373
- await fs.promises.appendFile(this.jsonlPath(sessionId), JSON.stringify(row) + "\n", "utf8");
374
- await this.touchLastOpened(sessionId, { titleIfEmpty: titleFromUserContent(content) });
521
+ await this.runSerialized(async () => {
522
+ await fs.promises.appendFile(this.jsonlPath(sessionId), JSON.stringify(row) + "\n", "utf8");
523
+ await this.touchLastOpenedUnlocked(sessionId, {
524
+ titleIfEmpty: titleFromUserContent(content),
525
+ });
526
+ });
375
527
  }
376
528
  async appendAssistant(sessionId, content) {
377
529
  if (!isSessionId(sessionId))
378
530
  throw new Error("invalid_session_id");
379
- await fs.promises.appendFile(this.jsonlPath(sessionId), JSON.stringify({ role: "assistant", content, ts: nowIso() }) + "\n", "utf8");
380
- await this.touchLastOpened(sessionId);
531
+ await this.runSerialized(async () => {
532
+ await fs.promises.appendFile(this.jsonlPath(sessionId), JSON.stringify({ role: "assistant", content, ts: nowIso() }) + "\n", "utf8");
533
+ await this.touchLastOpenedUnlocked(sessionId);
534
+ });
381
535
  }
382
536
  }
383
537
  export async function getAgentSessionForApi(visitorId, kind = "normal", documentId) {
@@ -395,4 +549,60 @@ export async function openAgentSessionForApi(visitorId, sessionId, kind = "norma
395
549
  export async function bindCodingSessionDocumentForApi(visitorId, sessionId, documentId) {
396
550
  return new AgentSessionManager(visitorId, { kind: "coding" }).bindSessionDocument(sessionId, documentId);
397
551
  }
552
+ /** 修复单个访客的 normal / coding session meta(按 kind)。 */
553
+ export async function repairAgentSessionsForVisitor(params) {
554
+ const kinds = params.kind === "coding"
555
+ ? ["coding"]
556
+ : params.kind === "normal"
557
+ ? ["normal"]
558
+ : ["normal", "coding"];
559
+ const rows = [];
560
+ for (const kind of kinds) {
561
+ const mgr = new AgentSessionManager(params.visitorId, { kind });
562
+ const r = await mgr.repairMetaFromDisk({ dryRun: params.dryRun });
563
+ rows.push({
564
+ visitorId: params.visitorId,
565
+ kind,
566
+ sessionDir: r.sessionDir,
567
+ before: r.before,
568
+ after: r.after,
569
+ added: r.added,
570
+ dryRun: r.dryRun,
571
+ });
572
+ }
573
+ return rows;
574
+ }
575
+ /** 扫描 dataDir/tenant/*,修复全部(或指定)访客的 session 索引。 */
576
+ export async function repairAgentSessionsUnderDataDir(params) {
577
+ const { dataDir } = getConfig();
578
+ const tenantRoot = path.join(dataDir, "tenant");
579
+ let visitorIds = [];
580
+ if (params.visitorId) {
581
+ visitorIds = [params.visitorId];
582
+ }
583
+ else {
584
+ try {
585
+ visitorIds = (await fs.promises.readdir(tenantRoot)).filter((name) => {
586
+ try {
587
+ return fs.statSync(path.join(tenantRoot, name)).isDirectory();
588
+ }
589
+ catch {
590
+ return false;
591
+ }
592
+ });
593
+ }
594
+ catch {
595
+ return [];
596
+ }
597
+ }
598
+ const out = [];
599
+ for (const visitorId of visitorIds) {
600
+ out.push(...(await repairAgentSessionsForVisitor({
601
+ visitorId,
602
+ kind: params.kind ?? "all",
603
+ dryRun: params.dryRun,
604
+ })));
605
+ }
606
+ return out;
607
+ }
398
608
  //# sourceMappingURL=session-manager.js.map