@morlay/session-rdb 0.0.21-alpha.5 → 0.0.21-alpha.6

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 (48) hide show
  1. package/README.md +53 -27
  2. package/dist/artifact.d.mts +27 -2
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{backend-a0xDrUFG.d.mts → backend-E0bmTXpz.d.mts} +233 -27
  5. package/dist/{branch-Dlmbkfjj.mjs → branch-BjUR6wfe.mjs} +31 -15
  6. package/dist/deletion.d.mts +1 -1
  7. package/dist/deletion.mjs +1 -1
  8. package/dist/import.d.mts +1 -1
  9. package/dist/import.mjs +2 -3
  10. package/dist/index.d.mts +2 -2
  11. package/dist/index.mjs +4 -4
  12. package/dist/{log-C-rbdFwK.mjs → log-qCNkDjh5.mjs} +93 -22
  13. package/dist/{sqlite-C3LHLzYW.mjs → sqlite-zcXVyMxr.mjs} +85 -10
  14. package/dist/{src-Bn0038Sp.mjs → src-B9X8BKyh.mjs} +210 -38
  15. package/dist/storage.d.mts +2 -1
  16. package/dist/storage.mjs +1 -1
  17. package/dist/testing.d.mts +2 -11
  18. package/dist/testing.mjs +2 -19
  19. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +1 -0
  20. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +2 -0
  21. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +10 -0
  22. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +7 -0
  23. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +1041 -0
  24. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +4 -0
  25. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +13 -0
  26. package/package.json +21 -21
  27. package/src/backend.ts +46 -1
  28. package/src/branch.ts +28 -11
  29. package/src/drizzle/postgres-v3.ts +1 -0
  30. package/src/drizzle/sqlite-v3.ts +1 -0
  31. package/src/entities/v3/event-usage.ts +5 -5
  32. package/src/entities/v3/sessions.ts +5 -0
  33. package/src/import.ts +3 -3
  34. package/src/index.ts +56 -22
  35. package/src/legacy.ts +14 -3
  36. package/src/log.ts +107 -22
  37. package/src/postgres.ts +65 -5
  38. package/src/rows.ts +125 -0
  39. package/src/schema.ts +3 -2
  40. package/src/session-query.ts +27 -1
  41. package/src/sqlite.ts +71 -3
  42. package/src/storage-takeover/projection-cache.ts +40 -15
  43. package/src/storage-takeover/repository.ts +34 -0
  44. package/src/storage-takeover/storage-backend.ts +3 -1
  45. package/src/testing/coordinator-contract.ts +2 -1
  46. package/src/testing.ts +0 -1
  47. package/src/write-guard.ts +5 -0
  48. package/src/testing/helpers.ts +0 -20
package/src/log.ts CHANGED
@@ -30,6 +30,7 @@ export function rowToMeta(row: SessionRow): SessionHeader {
30
30
  isSeeded: row.fSeedLength !== null,
31
31
  ...(row.fOrigin !== null ? { origin: row.fOrigin as "subagent" } : {}),
32
32
  ...(row.fDelegationDepth === null ? {} : { delegationDepth: row.fDelegationDepth }),
33
+ ...(row.fAgentPreset === null ? {} : { agentPreset: row.fAgentPreset }),
33
34
  };
34
35
  }
35
36
 
@@ -47,6 +48,7 @@ export function sessionInsertRow(
47
48
  fSeedLength: number | null;
48
49
  fOrigin: string | null;
49
50
  fDelegationDepth: number | null;
51
+ fAgentPreset: string | null;
50
52
  fIncarnation: string;
51
53
  fRevision: number;
52
54
  } {
@@ -62,6 +64,7 @@ export function sessionInsertRow(
62
64
  fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
63
65
  fOrigin: meta.origin ?? null,
64
66
  fDelegationDepth: meta.delegationDepth ?? null,
67
+ fAgentPreset: meta.agentPreset ?? null,
65
68
  fIncarnation: incarnation,
66
69
  fRevision: 0,
67
70
  };
@@ -75,6 +78,7 @@ export function sessionConflictRow(storage: SessionStorageMetadata): {
75
78
  fSeedLength: number | null;
76
79
  fOrigin: string | null;
77
80
  fDelegationDepth: number | null;
81
+ fAgentPreset: string | null;
78
82
  } {
79
83
  const meta = storage.meta;
80
84
  return {
@@ -85,6 +89,7 @@ export function sessionConflictRow(storage: SessionStorageMetadata): {
85
89
  fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
86
90
  fOrigin: meta.origin ?? null,
87
91
  fDelegationDepth: meta.delegationDepth ?? null,
92
+ fAgentPreset: meta.agentPreset ?? null,
88
93
  };
89
94
  }
90
95
 
@@ -206,32 +211,23 @@ function isDeepEqualJson(a: unknown, b: unknown): boolean {
206
211
  }
207
212
 
208
213
  function toolResultRewriteContentOnly(original: SessionEvent, replacement: SessionEvent): boolean {
214
+ // V4 起 `content` 就是结果内容本身(工具身份在消息顶层的 `toolCallId`),所以「只改结果」的
215
+ // 重写等于「除了 `content` 之外逐字相同」。
209
216
  const originalData = original.data as Record<string, unknown>;
210
217
  const replacementData = replacement.data as Record<string, unknown>;
211
218
  const originalMessage = originalData["message"] as { content?: unknown } | undefined;
212
219
  const replacementMessage = replacementData["message"] as { content?: unknown } | undefined;
213
- const originalContent = Array.isArray(originalMessage?.content)
214
- ? originalMessage.content
215
- : undefined;
216
- const replacementContent = Array.isArray(replacementMessage?.content)
217
- ? replacementMessage.content
218
- : undefined;
219
- if (originalContent === undefined || replacementContent === undefined) return false;
220
- const originalRest = {
221
- ...originalData,
222
- message: {
223
- ...originalMessage,
224
- content: [{ ...(originalContent[0] as Record<string, unknown>), content: null }],
225
- },
226
- };
227
- const replacementRest = {
228
- ...replacementData,
229
- message: {
230
- ...replacementMessage,
231
- content: [{ ...(replacementContent[0] as Record<string, unknown>), content: null }],
232
- },
233
- };
234
- return isDeepEqualJson(originalRest, replacementRest);
220
+ if (!Array.isArray(originalMessage?.content) || !Array.isArray(replacementMessage?.content)) {
221
+ return false;
222
+ }
223
+ const rest = (data: Record<string, unknown>, message: Record<string, unknown>): unknown => ({
224
+ ...data,
225
+ message: { ...message, content: null },
226
+ });
227
+ return isDeepEqualJson(
228
+ rest(originalData, originalMessage as Record<string, unknown>),
229
+ rest(replacementData, replacementMessage as Record<string, unknown>),
230
+ );
235
231
  }
236
232
 
237
233
  export function findSurfaceRepairs(events: readonly SessionEvent[]): {
@@ -464,6 +460,95 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
464
460
  }
465
461
  }
466
462
 
463
+ /**
464
+ * 把**旧代(v3 及更早)的消息形状**归一到当前格式(v4):迁移链(严格)拒绝那些形状时,读路径仍要
465
+ * 能把它们读出来——只动字段与标记,不改坐标、不改内容。
466
+ *
467
+ * 三件事:
468
+ * - `system/message` 的 source:`{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }` → `{ kind: 'system-prompt' }`
469
+ * (v4 起 system 消息只认这个 kind);
470
+ * - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
471
+ * `toolCallId` + `content` 就是结果块;
472
+ * - 本仓库自己写过、上游已删除的事件类型(见 `LEGACY_OWN_EVENT_TYPES`)标 `ignorable: true`——v4 的
473
+ * 校验对未知类型只接受可忽略信封。
474
+ *
475
+ * 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
476
+ */
477
+ export function normalizeToCurrentShape(events: SessionEvent[]): void {
478
+ for (const event of events) {
479
+ const type = (event as { type: string }).type;
480
+ if (LEGACY_OWN_EVENT_TYPES.has(type)) {
481
+ (event as { ignorable?: true }).ignorable = true;
482
+ continue;
483
+ }
484
+ if (type === "system/message") {
485
+ const message = (event.data as unknown as Record<string, unknown>)["message"];
486
+ liftSystemMessageSource(message);
487
+ continue;
488
+ }
489
+ if (type === "tool/result") {
490
+ const message = (event.data as unknown as Record<string, unknown>)["message"];
491
+ liftToolResultMessage(message);
492
+ }
493
+ }
494
+ }
495
+
496
+ function liftSystemMessageSource(message: unknown): void {
497
+ if (typeof message !== "object" || message === null) return;
498
+ const record = message as Record<string, unknown>;
499
+ const source = record["source"];
500
+ if (typeof source !== "object" || source === null) return;
501
+ if ((source as Record<string, unknown>)["kind"] !== "plugin") return;
502
+ record["source"] = { kind: "system-prompt" };
503
+ }
504
+
505
+ function liftToolResultMessage(message: unknown): void {
506
+ if (typeof message !== "object" || message === null) return;
507
+ const record = message as Record<string, unknown>;
508
+ if (record["role"] === "tool" && typeof record["toolCallId"] === "string") return;
509
+ const source = record["source"] as Record<string, unknown> | undefined;
510
+ const block = Array.isArray(record["content"]) ? record["content"][0] : undefined;
511
+ if (typeof block !== "object" || block === null) return;
512
+ const lifted = block as Record<string, unknown>;
513
+ const callId = lifted["toolCallId"] ?? source?.["callId"];
514
+ if (typeof callId !== "string") return;
515
+ record["role"] = "tool";
516
+ record["toolCallId"] = callId;
517
+ record["content"] = Array.isArray(lifted["content"]) ? lifted["content"] : [];
518
+ if (typeof lifted["isError"] === "boolean") record["isError"] = lifted["isError"];
519
+ record["source"] = { kind: "tool", callId };
520
+ }
521
+
522
+ /**
523
+ * 本仓库写过、后来连同机制一起删除的事件类型:读的时候标 `ignorable: true` 让校验跳过它们。
524
+ * 新增历史类型时登记到这里——**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件)。
525
+ */
526
+ export const LEGACY_OWN_EVENT_TYPES: ReadonlySet<string> = new Set(["session-branch/version"]);
527
+
528
+ /** 行里是否还带着旧代消息形状(`session-branch/version` 这类自造事件也算)——写路径曾把回退视图的结果
529
+ * 以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
530
+ export function hasLegacyShape(events: readonly SessionEvent[]): boolean {
531
+ for (const event of events) {
532
+ const type = (event as { type: string }).type;
533
+ if (LEGACY_OWN_EVENT_TYPES.has(type)) return true;
534
+ if (type === "system/message") {
535
+ const message = (event.data as unknown as Record<string, unknown>)["message"] as
536
+ | Record<string, unknown>
537
+ | undefined;
538
+ const source = message?.["source"] as Record<string, unknown> | undefined;
539
+ if (source?.["kind"] === "plugin") return true;
540
+ continue;
541
+ }
542
+ if (type === "tool/result") {
543
+ const message = (event.data as unknown as Record<string, unknown>)["message"] as
544
+ | Record<string, unknown>
545
+ | undefined;
546
+ if (message !== undefined && message["role"] !== "tool") return true;
547
+ }
548
+ }
549
+ return false;
550
+ }
551
+
467
552
  export function repairReadView(events: SessionEvent[]): void {
468
553
  repairAssistantSettlement(events);
469
554
  repairRequestHeaders(events);
package/src/postgres.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  type EventCountBucket,
14
14
  type EventInsert,
15
15
  type EventRow,
16
+ type SessionListRowsPage,
17
+ type SessionListRowsQuery,
16
18
  type SessionRow,
17
19
  } from "./backend.ts";
18
20
  import { toPostgresSchema } from "./adapters/index.ts";
@@ -283,7 +285,7 @@ export class PostgresBackend implements Backend {
283
285
  insertBridges: (rows) => this.insertBridges(tx, rows),
284
286
  updateHead: (id, headEventId, headSequence) =>
285
287
  this.updateHead(tx, id, headEventId, headSequence),
286
- bumpRevision: (id) => this.bumpRevision(tx, id),
288
+ bumpRevision: (id, lastEventAt) => this.bumpRevision(tx, id, lastEventAt),
287
289
  refreshTitle: (id) => this.refreshTitle(tx, id),
288
290
  deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
289
291
  getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence),
@@ -425,11 +427,18 @@ export class PostgresBackend implements Backend {
425
427
  private async bumpRevision(
426
428
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
427
429
  id: SessionId,
430
+ lastEventAt?: number,
428
431
  ): Promise<void> {
432
+ const sessions = this.tables["t_sessions"];
429
433
  await exec
430
- .update(this.tables["t_sessions"])
431
- .set({ fRevision: sql`${this.tables["t_sessions"].fRevision} + 1` })
432
- .where(eq(this.tables["t_sessions"].fSessionId, id))
434
+ .update(sessions)
435
+ .set({
436
+ fRevision: sql`${sessions.fRevision} + 1`,
437
+ ...(lastEventAt === undefined
438
+ ? {}
439
+ : { fLastEventAt: sql`GREATEST(COALESCE(${sessions.fLastEventAt}, 0), ${lastEventAt})` }),
440
+ })
441
+ .where(eq(sessions.fSessionId, id))
433
442
  .execute();
434
443
  }
435
444
 
@@ -598,6 +607,57 @@ export class PostgresBackend implements Backend {
598
607
  }
599
608
 
600
609
  /** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
610
+ async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
611
+ const needle = query.query?.trim().toLowerCase() ?? "";
612
+ const like = `%${needle}%`;
613
+ const includeSubagents = query.includeSubagents === true;
614
+ const limit = query.limit ?? 20;
615
+ const offset = query.offset ?? 0;
616
+ const tSessions = this.tables["t_sessions"];
617
+ const tWorkspaces = this.tables["t_workspaces"];
618
+ const tWorkspaceSessions = this.tables["t_workspace_sessions"];
619
+ // 归属可能有多行(一个会话可在多个工作区):取 position 最小的那条,避免放大结果行数。
620
+ const workspaceTitle = sql`(SELECT w.f_title FROM ${tWorkspaceSessions} b
621
+ JOIN ${tWorkspaces} w ON w.f_workspace_id = b.f_workspace_id
622
+ WHERE b.f_session_id = s.f_session_id
623
+ ORDER BY b.f_position LIMIT 1)`;
624
+ const where = sql`WHERE (${needle} = ''
625
+ OR lower(COALESCE(s.f_title, '')) LIKE ${like}
626
+ OR lower(COALESCE(${workspaceTitle}, '')) LIKE ${like})
627
+ AND (${includeSubagents} OR s.f_origin IS DISTINCT FROM 'subagent')`;
628
+ const rows = (await this.db.execute(sql`
629
+ SELECT s.f_session_id AS session_id,
630
+ s.f_title AS title,
631
+ s.f_origin AS origin,
632
+ s.f_cwd AS cwd,
633
+ s.f_created_at AS created_at,
634
+ (s.f_archived_at IS NOT NULL) AS archived,
635
+ COALESCE(s.f_last_event_at, s.f_created_at) AS updated_at,
636
+ ${workspaceTitle} AS workspace
637
+ FROM ${tSessions} s
638
+ ${where}
639
+ ORDER BY updated_at DESC, s.f_session_id
640
+ LIMIT ${limit} OFFSET ${offset}
641
+ `)) as unknown as { rows: Array<Record<string, unknown>> };
642
+ const counted = (await this.db.execute(sql`
643
+ SELECT count(*) AS n FROM ${tSessions} s ${where}
644
+ `)) as unknown as { rows: Array<Record<string, unknown>> };
645
+ return {
646
+ items: rows.rows.map((row) => ({
647
+ sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
648
+ title: typeof row["title"] === "string" ? row["title"] : null,
649
+ origin: typeof row["origin"] === "string" ? row["origin"] : null,
650
+ cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
651
+ createdAt: numeric(row["created_at"]),
652
+ updatedAt: numeric(row["updated_at"]),
653
+ archived: row["archived"] === true,
654
+ subagent: row["origin"] === "subagent",
655
+ workspace: typeof row["workspace"] === "string" ? row["workspace"] : null,
656
+ })),
657
+ total: numeric(counted.rows[0]?.["n"] ?? 0),
658
+ };
659
+ }
660
+
601
661
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
602
662
  const usageSince = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
603
663
  // 活动计数走派生表的「本地日」列(与桶的本地日口径一致)。
@@ -703,7 +763,7 @@ export class PostgresBackend implements Backend {
703
763
  ...tokenTotalsOf(row),
704
764
  })),
705
765
  sessions: tokenSessions.rows.map((row) => ({
706
- sessionId: String(row["session_id"]),
766
+ sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
707
767
  title: text(row["title"]),
708
768
  subagent: row["subagent"] === true,
709
769
  archived: row["archived"] === true,
package/src/rows.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { Backend } from "./backend.ts";
3
+
4
+ /**
5
+ * 管理面的会话列表:**我们自己的**一条路由(完整语料,含归档),与官方 `session/list` 分开。
6
+ *
7
+ * 官方那条(`session/list` → `ctx.sessionQuery.listSessions`)按部署策略默认排除已归档——那是给上游
8
+ * UI 用的。归档集的管理动作要有完整列表,所以这里单独给一条:标题直接从会话列取,最后活动时间用
9
+ * 该会话最后一个事件的时间。
10
+ */
11
+ export const SESSION_ROWS_PATH = "/api/session.rows";
12
+
13
+ export interface SessionRowsItem {
14
+ sessionId: string;
15
+ title: string | null;
16
+ /** 会话 header 的 origin(`subagent` 表示子代理会话)。 */
17
+ origin: string | null;
18
+ cwd: string | null;
19
+ createdAt: number;
20
+ /** 最后一个事件的时间(无事件时等于 `createdAt`)。 */
21
+ updatedAt: number;
22
+ archived: boolean;
23
+ /** 所属工作区标题;不属于任何工作区时为 null。 */
24
+ workspace: string | null;
25
+ }
26
+
27
+ /** 请求:搜索、子代理过滤与分页都在后端做——前端分页等于每次拉全量,数据一多就崩。 */
28
+ export interface SessionRowsRequest {
29
+ /** 匹配标题或所属工作区标题(大小写不敏感)。 */
30
+ query?: string;
31
+ includeSubagents?: boolean;
32
+ page?: number;
33
+ pageSize?: number;
34
+ }
35
+
36
+ export interface SessionRowsValue {
37
+ items: SessionRowsItem[];
38
+ /** 过滤后的总数(分页前),前端据此算页数。 */
39
+ total: number;
40
+ page: number;
41
+ pageSize: number;
42
+ }
43
+
44
+ /** 一页的条数上限:超过就按上限截断(这个接口给页面用,不是导出通道)。 */
45
+ const MAX_PAGE_SIZE = 200;
46
+ const DEFAULT_PAGE_SIZE = 20;
47
+
48
+ export function registerSessionRows(ctx: Context, backend: Backend): void {
49
+ ctx.inject(["webServer", "connection"] as const, (webCtx) => {
50
+ const webServer = webCtx.webServer as unknown as {
51
+ register(route: {
52
+ kind: "exact";
53
+ path: string;
54
+ handler: (
55
+ req: import("node:http").IncomingMessage,
56
+ res: import("node:http").ServerResponse,
57
+ ) => void | Promise<void>;
58
+ }): () => void;
59
+ };
60
+ const connection = webCtx.get("connection") as unknown as {
61
+ requestRejection(request: {
62
+ headers: import("node:http").IncomingHttpHeaders;
63
+ }): number | undefined;
64
+ };
65
+ return webCtx.effect(
66
+ () =>
67
+ webServer.register({
68
+ kind: "exact",
69
+ path: SESSION_ROWS_PATH,
70
+ handler: async (req, res) => {
71
+ const rejection = connection.requestRejection(req);
72
+ if (rejection !== undefined) {
73
+ res.writeHead(rejection);
74
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
75
+ return;
76
+ }
77
+ if (req.method !== "POST") {
78
+ res.writeHead(405, { "content-type": "application/json" });
79
+ res.end(JSON.stringify({ error: "method not allowed" }));
80
+ return;
81
+ }
82
+ const chunks: Buffer[] = [];
83
+ for await (const chunk of req) chunks.push(chunk as Buffer);
84
+ const raw = Buffer.concat(chunks).toString("utf8");
85
+ let envelope: SessionRowsRequest = {};
86
+ if (raw !== "") {
87
+ try {
88
+ envelope = JSON.parse(raw) as SessionRowsRequest;
89
+ } catch {
90
+ res.writeHead(400, { "content-type": "application/json" });
91
+ res.end(JSON.stringify({ error: "request body is not JSON" }));
92
+ return;
93
+ }
94
+ }
95
+ const page = Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
96
+ const requested = envelope.pageSize;
97
+ const pageSize =
98
+ Number.isSafeInteger(requested) && (requested ?? 0) > 0
99
+ ? Math.min(requested!, MAX_PAGE_SIZE)
100
+ : DEFAULT_PAGE_SIZE;
101
+ try {
102
+ const { items, total } = await backend.listSessionRows({
103
+ ...(envelope.query === undefined ? {} : { query: envelope.query }),
104
+ ...(envelope.includeSubagents === undefined
105
+ ? {}
106
+ : { includeSubagents: envelope.includeSubagents }),
107
+ limit: pageSize,
108
+ offset: (page - 1) * pageSize,
109
+ });
110
+ res.writeHead(200, { "content-type": "application/json" });
111
+ res.end(JSON.stringify({ items, total, page, pageSize } satisfies SessionRowsValue));
112
+ } catch (error: unknown) {
113
+ res.writeHead(500, { "content-type": "application/json" });
114
+ res.end(
115
+ JSON.stringify({
116
+ error: error instanceof Error ? error.message : "session rows failed",
117
+ }),
118
+ );
119
+ }
120
+ },
121
+ }),
122
+ "session-rdb: session rows route",
123
+ );
124
+ });
125
+ }
package/src/schema.ts CHANGED
@@ -160,8 +160,9 @@ export function eventDimensions(event: SessionEvent): {
160
160
  case "assistant/message":
161
161
  return { kind, role: "assistant", name: "", actionId: "" };
162
162
  case "tool/result": {
163
- const message = data["message"] as { content?: Array<{ toolCallId?: string }> } | undefined;
164
- return { kind, role: "tool", name: "", actionId: message?.content?.[0]?.toolCallId ?? "" };
163
+ // V4 起结果消息自己带 `toolCallId`(不再包在 content 的一个 `tool-result` 块里)。
164
+ const message = data["message"] as { toolCallId?: string } | undefined;
165
+ return { kind, role: "tool", name: "", actionId: message?.toolCallId ?? "" };
165
166
  }
166
167
  case "tool/call":
167
168
  return {
@@ -1,11 +1,37 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
- import { SessionQueryEngine, SessionQueryError, type Config } from "@deepseek-ai/dsh-session-query";
2
+ import {
3
+ SessionQueryEngine,
4
+ SessionQueryError,
5
+ type Config,
6
+ type SessionRecord,
7
+ } from "@deepseek-ai/dsh-session-query";
3
8
 
4
9
  export class SessionQueryRdb extends SessionQueryEngine {
5
10
  constructor(ctx: Context, config: Config = {}) {
6
11
  super(ctx, config);
7
12
  }
8
13
 
14
+ /**
15
+ * 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,列表与搜索都不该把它混进
16
+ * 日常集合(上游 client 再用 workspace registry 的归档集做「只看归档」的派生)。
17
+ *
18
+ * 完整语料(含归档)走我们自己的管理面路由 `SESSION_ROWS_PATH`——两条路分开,不在一处混。
19
+ */
20
+ override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
21
+ const records = await super.listSessions(signal);
22
+ const archived = this.archivedSessionIds();
23
+ if (archived.size === 0) return records;
24
+ return records.filter((record) => !archived.has(String(record.header.id)));
25
+ }
26
+
27
+ /** 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。 */
28
+ private archivedSessionIds(): ReadonlySet<string> {
29
+ const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
30
+ | { archivedSessionIds?: readonly string[] }
31
+ | undefined;
32
+ return new Set<string>(registry?.archivedSessionIds ?? []);
33
+ }
34
+
9
35
  override async searchSessions(): Promise<never> {
10
36
  throw searchDisabled();
11
37
  }
package/src/sqlite.ts CHANGED
@@ -14,6 +14,9 @@ import {
14
14
  type EventCountBucket,
15
15
  type EventInsert,
16
16
  type EventRow,
17
+ type SessionListRowRecord,
18
+ type SessionListRowsPage,
19
+ type SessionListRowsQuery,
17
20
  type SessionRow,
18
21
  } from "./backend.ts";
19
22
  import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
@@ -139,6 +142,20 @@ function backfillEventUsage(db: DatabaseSync): void {
139
142
  OR json_extract(e.f_data, '$.usage') IS NOT NULL)`);
140
143
  }
141
144
 
145
+ function sessionListRowOf(row: Record<string, unknown>): SessionListRowRecord {
146
+ return {
147
+ sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
148
+ title: typeof row["title"] === "string" ? row["title"] : null,
149
+ origin: typeof row["origin"] === "string" ? row["origin"] : null,
150
+ cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
151
+ createdAt: Number(row["created_at"]),
152
+ updatedAt: Number(row["updated_at"]),
153
+ archived: row["archived"] === 1 || row["archived"] === true,
154
+ subagent: row["origin"] === "subagent",
155
+ workspace: typeof row["workspace"] === "string" ? row["workspace"] : null,
156
+ };
157
+ }
158
+
142
159
  /** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
143
160
  function tokenTotalsOf(row: {
144
161
  input_tokens: number | null;
@@ -495,7 +512,7 @@ export class SqliteBackend implements Backend {
495
512
  insertEvents: (events) => this.insertEvents(events),
496
513
  insertBridges: (rows) => this.insertBridges(rows),
497
514
  updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
498
- bumpRevision: (id) => this.bumpRevision(id),
515
+ bumpRevision: (id, lastEventAt) => this.bumpRevision(id, lastEventAt),
499
516
  refreshTitle: (id) => this.refreshTitle(id),
500
517
  deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(id, fromSequence),
501
518
  getPrevBridge: (id, sequence) => this.getPrevBridge(id, sequence),
@@ -595,10 +612,15 @@ export class SqliteBackend implements Backend {
595
612
  .run();
596
613
  }
597
614
 
598
- private async bumpRevision(id: SessionId): Promise<void> {
615
+ private async bumpRevision(id: SessionId, lastEventAt?: number): Promise<void> {
599
616
  this.db
600
617
  .update(tSessions)
601
- .set({ fRevision: sql`${tSessions.fRevision} + 1` })
618
+ .set({
619
+ fRevision: sql`${tSessions.fRevision} + 1`,
620
+ ...(lastEventAt === undefined
621
+ ? {}
622
+ : { fLastEventAt: sql`MAX(COALESCE(${tSessions.fLastEventAt}, 0), ${lastEventAt})` }),
623
+ })
602
624
  .where(eq(tSessions.fSessionId, id))
603
625
  .run();
604
626
  }
@@ -692,6 +714,52 @@ export class SqliteBackend implements Backend {
692
714
  * 沿 `t_events` 的事件类型数——两者限定在**有 token 用量的会话**里,时间范围各自按自己的
693
715
  * `f_created_at`(同一时刻写入,口径一致)。
694
716
  */
717
+ async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
718
+ const needle = query.query?.trim().toLowerCase() ?? "";
719
+ const like = `%${needle}%`;
720
+ // 排序键用物化列:`f_last_event_at` 由写路径维护、老数据由迁移回填,没有事件的会话回落 createdAt。
721
+ // 早先这里对每行算一次「该会话最后事件时间」的相关子查询——243 行要 4.2s,分页也救不了(排序要求
722
+ // 全表先算出来)。
723
+ const where = `WHERE (? = '' OR lower(COALESCE(s.f_title, '')) LIKE ? OR lower(COALESCE(w.f_title, '')) LIKE ?)
724
+ AND (? = 1 OR s.f_origin IS NOT 'subagent')`;
725
+ const filterParams = [needle, like, like, query.includeSubagents === true ? 1 : 0];
726
+ const limit = query.limit ?? 20;
727
+ const offset = query.offset ?? 0;
728
+ const rows = this.db.$client
729
+ .prepare(
730
+ `SELECT s.f_session_id AS session_id,
731
+ s.f_title AS title,
732
+ s.f_origin AS origin,
733
+ s.f_cwd AS cwd,
734
+ s.f_created_at AS created_at,
735
+ (s.f_archived_at IS NOT NULL) AS archived,
736
+ COALESCE(s.f_last_event_at, s.f_created_at) AS updated_at,
737
+ w.f_title AS workspace
738
+ FROM t_sessions s
739
+ LEFT JOIN t_workspaces w ON w.f_workspace_id = (
740
+ SELECT b.f_workspace_id FROM t_workspace_sessions b
741
+ WHERE b.f_session_id = s.f_session_id ORDER BY b.f_position LIMIT 1)
742
+ ${where}
743
+ ORDER BY updated_at DESC, s.f_session_id
744
+ LIMIT ? OFFSET ?`,
745
+ )
746
+ .all(...filterParams, limit, offset) as unknown as Array<Record<string, unknown>>;
747
+ const counted = this.db.$client
748
+ .prepare(
749
+ `SELECT count(*) AS n
750
+ FROM t_sessions s
751
+ LEFT JOIN t_workspaces w ON w.f_workspace_id = (
752
+ SELECT b.f_workspace_id FROM t_workspace_sessions b
753
+ WHERE b.f_session_id = s.f_session_id ORDER BY b.f_position LIMIT 1)
754
+ ${where}`,
755
+ )
756
+ .get(...filterParams) as { n: number };
757
+ return {
758
+ items: rows.map((row) => sessionListRowOf(row)),
759
+ total: Number(counted.n),
760
+ };
761
+ }
762
+
695
763
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
696
764
  const usageSince = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
697
765
  const params = sinceMs === undefined ? [] : [sinceMs];