@aa2246740/dsh-resume 0.2.2 → 0.2.5

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.
package/README.en.md CHANGED
@@ -2,28 +2,49 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
- ```sh
6
- dsh plugin --profile web add github:aa2246740/dsh-resume
7
- ```
5
+ Bring local Codex, Claude Code, Cursor, Grok, Pi, and ZCode session context into DeepSeek Harness to continue unfinished work.
6
+
7
+ ## Install
8
+
9
+ **Version 0.2.5 is verified against DeepSeek Harness 0.2.0-rc.2.** Both the package and repository contain compiled plugin files; no Harness source checkout or local build is required. Python 3 is required to read history. Compressed Codex rollouts additionally require `zstd`.
10
+
11
+ ### Official Desktop app
12
+
13
+ 1. Open **Plugins → Add plugin** (under Settings in some versions).
14
+ 2. Enter `github:aa2246740/dsh-resume#v0.2.5` in **Package name or address**, then install and enable it.
15
+ 3. Follow the installer's activation instructions. Type `/resume-` in the composer and check that all six commands below appear.
16
+
17
+ Alternatively, download `dsh-resume-0.2.5.tgz` from the [v0.2.5 Release](https://github.com/aa2246740/dsh-resume/releases/tag/v0.2.5) and enter its absolute path in the same installer. Desktop uses its own profile; do not use Web Host commands to install into Desktop.
8
18
 
9
- `dsh plugin add` runs **pnpm** in the web profile. `dsh` (or `npx @deepseek-ai/dsh`) and pnpm must be on `PATH`. The command only writes the profile; it does not hot-load a running Host. Then restart that Host and reload the page. Confirm the slash commands appeared. Do not mount it through both a bundle and a patch.
19
+ ### Web Host
10
20
 
11
- Built for official DeepSeek Harness `0.1.7-rc.1`. This repo commits `lib/` and declares `dsh.bundle.patch`, so a `github:` install does not need a build. Node.js `22.19+` or `24+`, Python 3. `zstd` is only needed for compressed Codex rollouts.
21
+ Use the official CLI:
12
22
 
13
- From a clone:
23
+ ```sh
24
+ dsh plugin --profile web add github:aa2246740/dsh-resume#v0.2.5
25
+ ```
26
+
27
+ Or, from the downloaded archive's directory:
14
28
 
15
29
  ```sh
16
- git clone https://github.com/aa2246740/dsh-resume.git
17
- dsh plugin --profile web add ./dsh-resume
30
+ dsh plugin --profile web add ./dsh-resume-0.2.5.tgz
18
31
  ```
19
32
 
33
+ The official `dsh` command and pnpm must be available. Node.js must be `^22.19.0` or `>=24.0.0`. Use the existing Web Host's `DSH_HOME`. This only installs into the Web profile, not Desktop. Follow the official installer's activation result. If it requests a restart, exit and reopen the original Host normally; do not start another Host alongside it.
34
+
35
+ ### Upgrade and remove
36
+
37
+ Repeat the matching installation steps to upgrade. If migrating from a manual mount, disable the old duplicate entry and retain only the package's `dsh-resume` instance.
38
+
39
+ Remove through the Desktop plugin manager, or for Web:
40
+
20
41
  ```sh
21
42
  dsh plugin --profile web remove dsh-resume
22
43
  ```
23
44
 
24
- Continue unfinished Codex, Claude Code, Cursor, Grok, or Pi work inside DeepSeek Harness.
45
+ ## Usage
25
46
 
26
- Type `/resume-claude`, or the matching command for the other four. The plugin reads that local session, pulls context that can still be handed off, and writes a card into the current DSH chat. It does not restart the old process or rewrite anyone else's session files.
47
+ Type `/resume-claude`, or the matching command for the other five. The plugin reads that local session, pulls context that can still be handed off, and writes a card into the current DSH chat. It does not restart the old process or rewrite anyone else's session files.
27
48
 
28
49
  ![Composer with /resume-claude typed](docs/screenshots/composer-resume-claude.png)
29
50
 
@@ -37,7 +58,7 @@ The six sections are objective, files, done, remaining, stopped at, and reader w
37
58
 
38
59
  ## Commands
39
60
 
40
- Read-only. Foreign session stores are not modified. System prompts, hidden reasoning, encrypted or corrupt records are dropped or marked unavailable. History conclusions start as `HISTORY_REPORTED`. Only facts checked in the current workspace this turn are `CURRENT_OBSERVED`. Grok reads visible `updates.jsonl` only. Pi follows the current branch only.
61
+ Read-only. Foreign session stores are not modified. System prompts, hidden reasoning, encrypted or corrupt records are dropped or marked unavailable. History conclusions start as `HISTORY_REPORTED`. Only facts checked in the current workspace this turn are `CURRENT_OBSERVED`. Grok reads visible `updates.jsonl` only. Pi follows the current branch only. ZCode reads `cli/db/db.sqlite` only and does not replay calls or revive the CLI. Compaction is a warning; older rows still in the database are kept. Abandoned `v2/sessions` and `cli/rollout` JSONL are not the primary store. Projects JSONL is a warned fallback only when sqlite is absent.
41
62
 
42
63
  | Command | Source |
43
64
  | --- | --- |
@@ -46,6 +67,7 @@ Read-only. Foreign session stores are not modified. System prompts, hidden reaso
46
67
  | `/resume-cursor [latest \| session id \| path \| title]` | Cursor |
47
68
  | `/resume-grok [latest \| session id \| path \| title]` | Grok |
48
69
  | `/resume-pi [latest \| session id \| path \| title]` | Pi |
70
+ | `/resume-zcode [latest \| session id \| path \| title]` | ZCode |
49
71
 
50
72
  ## Develop
51
73
 
package/README.md CHANGED
@@ -1,29 +1,70 @@
1
- # dsh-resume
1
+ # npm installation for DSH 0.2.0-rc.2
2
2
 
3
- [English](README.en.md)
3
+ This npm distribution uses the existing `@aa2246740/dsh-resume` package. Install it under the original `dsh-resume` alias so the bundled Cordis module and client IDs keep resolving correctly.
4
+
5
+ In the official Desktop plugin manager, enter:
6
+
7
+ ```text
8
+ dsh-resume@npm:@aa2246740/dsh-resume@0.2.5
9
+ ```
10
+
11
+ For Web:
4
12
 
5
13
  ```sh
6
- dsh plugin --profile web add github:aa2246740/dsh-resume
14
+ dsh plugin --profile web add dsh-resume@npm:@aa2246740/dsh-resume@0.2.5
7
15
  ```
8
16
 
9
- `dsh plugin add` 在 web profile 里跑 **pnpm**。PATH 上要有 `dsh`(或 `npx @deepseek-ai/dsh`)和 pnpm。这条命令只写 profile,不会热挂正在跑的 Host。然后重启这个 Host,刷新页面。确认斜杠命令已经出现。不要同时用 bundle 和 patch 挂两份。
17
+ The runtime files match the [dsh-resume v0.2.5 release](https://github.com/aa2246740/dsh-resume/releases/tag/v0.2.5). Only the npm package name, publish configuration, and this installation note differ.
18
+
19
+ ---
20
+
21
+ # dsh-resume
22
+
23
+ [English](README.en.md)
24
+
25
+ 把 Codex、Claude Code、Cursor、Grok、Pi、ZCode 的本机会话整理成可继续工作的上下文,在 DeepSeek Harness 中接着做。
26
+
27
+ ## 安装
10
28
 
11
- 面向官方 DeepSeek Harness `0.1.7-rc.1`。仓库已提交编好的 `lib/`,并声明了 `dsh.bundle.patch`,所以 `github:` 安装不用再构建。Node.js `22.19+` 或 `24+`,Python 3。Codex 的压缩 rollout 才需要 `zstd`。
29
+ **0.2.5 面向 DeepSeek Harness 0.2.0-rc.2 验证。** 安装包和仓库均包含编译好的插件,无需下载 Harness 源码或自行构建。读取历史需要本机有 Python 3;读取压缩的 Codex rollout 另需 `zstd`。
12
30
 
13
- 本地 clone:
31
+ ### 官方桌面端
32
+
33
+ 1. 打开 **插件 → 添加插件**(部分版本入口在设置里)。
34
+ 2. 在 **包名或地址** 中填入 `github:aa2246740/dsh-resume#v0.2.5`,然后安装并启用。
35
+ 3. 按安装器提示完成激活,在输入框输入 `/resume-`,确认能看到下面列出的六条命令。
36
+
37
+ 也可以在 [v0.2.5 Release](https://github.com/aa2246740/dsh-resume/releases/tag/v0.2.5) 下载 `dsh-resume-0.2.5.tgz`,把该文件的绝对路径填入同一安装框。桌面端使用自己的 desktop profile,无需运行 Web Host 命令。
38
+
39
+ ### Web Host
40
+
41
+ 使用官方 CLI:
42
+
43
+ ```sh
44
+ dsh plugin --profile web add github:aa2246740/dsh-resume#v0.2.5
45
+ ```
46
+
47
+ 或在下载的安装包所在目录运行:
14
48
 
15
49
  ```sh
16
- git clone https://github.com/aa2246740/dsh-resume.git
17
- dsh plugin --profile web add ./dsh-resume
50
+ dsh plugin --profile web add ./dsh-resume-0.2.5.tgz
18
51
  ```
19
52
 
53
+ 需要官方 `dsh` 和 pnpm 可用,Node.js 版本为 `^22.19.0` 或 `>=24.0.0`。使用与现有 Web Host 相同的 `DSH_HOME`;这条命令只安装到 Web profile,不会替桌面端安装。按官方安装器返回的提示完成激活;只有它要求重启时,再正常退出并重开原来的 Host,不要启动第二个 Host。
54
+
55
+ ### 升级与卸载
56
+
57
+ 升级时重复对应安装步骤即可。若从旧版手动挂载方式迁移,停用旧的重复挂载行,只保留包自带的 `dsh-resume` 实例。
58
+
59
+ 桌面端通过插件管理器卸载。Web Host 使用:
60
+
20
61
  ```sh
21
62
  dsh plugin --profile web remove dsh-resume
22
63
  ```
23
64
 
24
- 在 DeepSeek Harness 里接着做 Codex、Claude Code、Cursor、Grok、Pi 没做完的事。
65
+ ## 使用
25
66
 
26
- 输入 `/resume-claude`,另外四条同理。它只读本机那次会话,抽出还能接手的上下文,写成一张交接卡。旧进程不会被拉起来,别人的会话文件也不会被改。
67
+ 输入 `/resume-claude`,另外五条同理。它只读本机那次会话,抽出还能接手的上下文,写成一张交接卡。旧进程不会被拉起来,别人的会话文件也不会被改。
27
68
 
28
69
  ![官方 composer 已输入 /resume-claude](docs/screenshots/composer-resume-claude.png)
29
70
 
@@ -37,7 +78,7 @@ dsh plugin --profile web remove dsh-resume
37
78
 
38
79
  ## 命令
39
80
 
40
- 只读,不改任何一家的会话仓库。系统提示、隐藏推理、加密或坏掉的记录要么丢掉,要么标明不可用。历史里的结论先标成 `HISTORY_REPORTED`,这轮在当前仓库里核对过的才是 `CURRENT_OBSERVED`。Grok 只读看得见的 `updates.jsonl`。Pi 只跟当前这条分支。
81
+ 只读,不改任何一家的会话仓库。系统提示、隐藏推理、加密或坏掉的记录要么丢掉,要么标明不可用。历史里的结论先标成 `HISTORY_REPORTED`,这轮在当前仓库里核对过的才是 `CURRENT_OBSERVED`。Grok 只读看得见的 `updates.jsonl`。Pi 只跟当前这条分支。ZCode 只读 `cli/db/db.sqlite`,不回放调用、不复活进程。压缩只标成警告,库里仍在的更早记录会保留。废弃的 `v2/sessions` 和 `cli/rollout` JSONL 不是主存储;只有 sqlite 不在时,才对 `projects/` 下的 JSONL 做带警告的尽力读取。
41
82
 
42
83
  | 命令 | 从哪接着 |
43
84
  | --- | --- |
@@ -46,6 +87,7 @@ dsh plugin --profile web remove dsh-resume
46
87
  | `/resume-cursor [latest \| 会话 id \| 路径 \| 标题]` | Cursor |
47
88
  | `/resume-grok [latest \| 会话 id \| 路径 \| 标题]` | Grok |
48
89
  | `/resume-pi [latest \| 会话 id \| 路径 \| 标题]` | Pi |
90
+ | `/resume-zcode [latest \| 会话 id \| 路径 \| 标题]` | ZCode |
49
91
 
50
92
  ## 开发
51
93
 
package/lib/dsh-resume.js CHANGED
@@ -9,7 +9,8 @@ const FOREIGN_SESSION_PROVIDERS = [
9
9
  "codex",
10
10
  "cursor",
11
11
  "grok",
12
- "pi"
12
+ "pi",
13
+ "zcode"
13
14
  ];
14
15
  const FOREIGN_SESSION_ACTIONS = ["show", "list"];
15
16
  /**
@@ -267,9 +268,16 @@ const RESUME_SKILL_SPECS = [
267
268
  product: "Pi",
268
269
  description: "继续当前工作目录中的 Pi 会话;可附会话 ID、JSONL 路径或标题关键词。",
269
270
  recoveryBoundary: "The reader follows Pi's current active leaf only and excludes thinking, hooks, system messages, and extension-injected records."
271
+ },
272
+ {
273
+ name: "resume-zcode",
274
+ provider: "zcode",
275
+ product: "ZCode",
276
+ description: "继续当前工作目录中的 ZCode 会话;可附会话 ID、记录路径或标题关键词。",
277
+ recoveryBoundary: "The reader uses the ZCode sqlite store at cli/db/db.sqlite and does not replay calls or revive the CLI. Abandoned v2 sessions and cli/rollout JSONL are ignored. Projects JSONL is a warned fallback only when sqlite is absent. Compaction is a summary marker; older rows still stored are kept."
270
278
  }
271
279
  ];
272
- /** One source of truth for all five user-only slash skills. */
280
+ /** One source of truth for all six user-only slash skills. */
273
281
  function skillRegistration(spec) {
274
282
  return {
275
283
  name: spec.name,
@@ -344,14 +352,14 @@ const JSON_TEXT_OUTPUT = {
344
352
  text: value
345
353
  }]
346
354
  };
347
- /** Register one read-only reader tool and five user-only slash skills. */
355
+ /** Register one read-only reader tool and six user-only slash skills. */
348
356
  function apply(ctx) {
349
357
  if (!existsSync(SESSION_READER_PATH)) throw new Error(`dsh-resume: missing bundled reader at ${SESSION_READER_PATH}`);
350
358
  if (!existsSync(GROK_SESSION_READER_PATH)) throw new Error(`dsh-resume: missing pinned Grok reader at ${GROK_SESSION_READER_PATH}`);
351
359
  console.log("[dsh-resume] loaded");
352
360
  ctx.tools.register(defineTool({
353
361
  name: "foreign_session_read",
354
- description: "Read Codex, Claude Code, Cursor, Grok, or Pi local session history into a Grok-compatible inert JSON schema. Use only after an explicit resume slash skill or explicit user request. Discovery is scoped to the current DSH working directory; an explicit native ID or path is supported. The tool is read-only and never executes recovered calls.",
362
+ description: "Read Codex, Claude Code, Cursor, Grok, Pi, or ZCode local session history into a Grok-compatible inert JSON schema. Use only after an explicit resume slash skill or explicit user request. Discovery is scoped to the current DSH working directory; an explicit native ID or path is supported. The tool is read-only and never executes recovered calls.",
355
363
  parameters: {
356
364
  provider: {
357
365
  type: "string",
@@ -1,7 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  export declare const name = "dsh-resume";
3
3
  export declare const inject: string[];
4
- /** Register one read-only reader tool and five user-only slash skills. */
4
+ /** Register one read-only reader tool and six user-only slash skills. */
5
5
  export declare function apply(ctx: Context): void;
6
6
  export { buildReaderArgs, GROK_SESSION_READER_PATH, runSessionReader, SESSION_READER_PATH, slashReferenceFromSession, slashReferenceFromUserText, } from './reader.ts';
7
7
  export { RESUME_SKILL_SPECS, resumeSkillContent, skillRegistration } from './skills.ts';
@@ -1,4 +1,4 @@
1
- export declare const FOREIGN_SESSION_PROVIDERS: readonly ["claude", "codex", "cursor", "grok", "pi"];
1
+ export declare const FOREIGN_SESSION_PROVIDERS: readonly ["claude", "codex", "cursor", "grok", "pi", "zcode"];
2
2
  export type ForeignSessionProvider = typeof FOREIGN_SESSION_PROVIDERS[number];
3
3
  export declare const FOREIGN_SESSION_ACTIONS: readonly ["show", "list"];
4
4
  export type ForeignSessionAction = typeof FOREIGN_SESSION_ACTIONS[number];
@@ -37,8 +37,14 @@ export declare const RESUME_SKILL_SPECS: readonly [{
37
37
  readonly product: "Pi";
38
38
  readonly description: "继续当前工作目录中的 Pi 会话;可附会话 ID、JSONL 路径或标题关键词。";
39
39
  readonly recoveryBoundary: "The reader follows Pi's current active leaf only and excludes thinking, hooks, system messages, and extension-injected records.";
40
+ }, {
41
+ readonly name: "resume-zcode";
42
+ readonly provider: "zcode";
43
+ readonly product: "ZCode";
44
+ readonly description: "继续当前工作目录中的 ZCode 会话;可附会话 ID、记录路径或标题关键词。";
45
+ readonly recoveryBoundary: "The reader uses the ZCode sqlite store at cli/db/db.sqlite and does not replay calls or revive the CLI. Abandoned v2 sessions and cli/rollout JSONL are ignored. Projects JSONL is a warned fallback only when sqlite is absent. Compaction is a summary marker; older rows still stored are kept.";
40
46
  }];
41
- /** One source of truth for all five user-only slash skills. */
47
+ /** One source of truth for all six user-only slash skills. */
42
48
  export declare function skillRegistration(spec: ResumeSkillSpec): SkillRegistration;
43
49
  export declare function resumeSkillContent(spec: ResumeSkillSpec): string;
44
50
  //# sourceMappingURL=skills.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aa2246740/dsh-resume",
3
- "version": "0.2.2",
4
- "description": "Continue Codex, Claude Code, Cursor, Grok, and Pi work in DeepSeek Harness.",
3
+ "version": "0.2.5",
4
+ "description": "Continue Codex, Claude Code, Cursor, Grok, Pi, and ZCode work in DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/dsh-resume.js",
7
7
  "types": "lib/types/dsh-resume.d.ts",
@@ -52,30 +52,31 @@
52
52
  "cursor",
53
53
  "grok",
54
54
  "pi-coding-agent",
55
+ "zcode",
55
56
  "session-handoff"
56
57
  ],
57
58
  "peerDependencies": {
58
59
  "@deepseek-ai/cordis": "^4.0.2",
59
- "@deepseek-ai/dsh-skill": ">=0.1.7-rc.1 <0.1.8",
60
- "@deepseek-ai/dsh-tools": ">=0.1.7-rc.1 <0.1.8"
60
+ "@deepseek-ai/dsh-skill": ">=0.2.0-rc.1 <0.2.1",
61
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.2.1"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@deepseek-ai/cordis": "4.0.4",
64
- "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
65
- "@deepseek-ai/dsh-invariants": "0.1.7-rc.1",
66
- "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
67
- "@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.1",
68
- "@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
69
- "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.1",
70
- "@deepseek-ai/dsh-scope": "0.1.7-rc.1",
71
- "@deepseek-ai/dsh-session": "0.1.7-rc.1",
72
- "@deepseek-ai/dsh-session-projection": "0.1.7-rc.1",
73
- "@deepseek-ai/dsh-skill": "0.1.7-rc.1",
74
- "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
75
- "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
76
- "@deepseek-ai/dsh-user-approval": "0.1.7-rc.1",
77
- "@types/node": "^22.20.0",
65
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
66
+ "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
67
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
68
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2",
69
+ "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
70
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
71
+ "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
72
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
73
+ "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
76
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
77
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
78
78
  "semver": "^7.7.2",
79
+ "@types/node": "^22.20.0",
79
80
  "tsdown": "^0.22.2",
80
81
  "tsx": "^4.22.4",
81
82
  "typescript": "^6.0.3"
@@ -84,5 +85,9 @@
84
85
  "bundle": {
85
86
  "patch": "./cordis.patch.yml"
86
87
  }
88
+ },
89
+ "publishConfig": {
90
+ "access": "public",
91
+ "registry": "https://registry.npmjs.org/"
87
92
  }
88
93
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Extend Grok's foreign-session reader with inert Grok and Pi adapters."""
2
+ """Extend Grok's foreign-session reader with inert Grok, Pi, and ZCode adapters."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -7,9 +7,13 @@ import argparse
7
7
  import json
8
8
  import os
9
9
  import re
10
+ import shutil
11
+ import sqlite3
10
12
  import sys
13
+ import tempfile
14
+ from contextlib import contextmanager
11
15
  from pathlib import Path
12
- from typing import Any, Iterable
16
+ from typing import Any, Iterable, Iterator
13
17
  from urllib.parse import quote
14
18
 
15
19
  import session_reader as core
@@ -21,7 +25,7 @@ for _stream in (sys.stdout, sys.stderr):
21
25
  pass
22
26
 
23
27
 
24
- EXTENDED_TOOLS = ("grok", "pi")
28
+ EXTENDED_TOOLS = ("grok", "pi", "zcode")
25
29
  TOOLS = (*core.TOOLS, *EXTENDED_TOOLS)
26
30
 
27
31
 
@@ -124,6 +128,10 @@ def _append_text_turn(
124
128
 
125
129
  class ProviderAdapter:
126
130
  tool: str
131
+ warnings: list[dict[str, str]]
132
+
133
+ def __init__(self) -> None:
134
+ self.warnings = []
127
135
 
128
136
  def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
129
137
  raise NotImplementedError
@@ -956,9 +964,785 @@ class PiAdapter(ProviderAdapter):
956
964
  return core._finalize_result(result)
957
965
 
958
966
 
967
+ _ZCODE_REQUIRED_COLUMNS = {
968
+ "session": ("id", "directory", "time_created", "time_updated"),
969
+ "message": ("id", "session_id", "data"),
970
+ "part": ("message_id", "data"),
971
+ }
972
+ _ZCODE_SESSION_OPTIONAL = ("title", "task_type", "parent_id", "data", "slug", "version")
973
+ _ZCODE_HIDDEN_PARTS = {"reasoning", "thinking", "redacted_reasoning", "redacted-thinking"}
974
+ _ZCODE_STRUCTURAL_PARTS = {"step-start", "step-finish", "step-end"}
975
+ _ZCODE_JSONL_FALLBACK_WARNING = (
976
+ "ZCode sqlite store was absent; used best-effort JSONL under projects/. "
977
+ "The primary store is cli/db/db.sqlite. Abandoned v2/sessions and cli/rollout JSONL are not read."
978
+ )
979
+
980
+
981
+ def _parse_data_object(raw: Any) -> dict[str, Any] | None:
982
+ if isinstance(raw, bytes):
983
+ raw = raw.decode("utf-8", errors="replace")
984
+ if isinstance(raw, str):
985
+ if not raw.strip():
986
+ return None
987
+ try:
988
+ raw = json.loads(raw)
989
+ except json.JSONDecodeError:
990
+ return None
991
+ if isinstance(raw, dict):
992
+ return raw
993
+ return None
994
+
995
+
996
+ def _row_value(row: sqlite3.Row, name: str) -> Any:
997
+ return row[name] if name in row.keys() else None
998
+
999
+
1000
+ def _is_abandoned_zcode_path(path: Path) -> bool:
1001
+ parts = [part.casefold() for part in path.expanduser().parts]
1002
+ for index, part in enumerate(parts):
1003
+ if part == "v2" and index + 1 < len(parts) and parts[index + 1] == "sessions":
1004
+ return True
1005
+ if part == "cli" and index + 1 < len(parts) and parts[index + 1] == "rollout":
1006
+ return True
1007
+ return False
1008
+
1009
+
1010
+ def _connect_sqlite_snapshot(path: Path) -> tuple[sqlite3.Connection, tempfile.TemporaryDirectory[str] | None]:
1011
+ """Open a private snapshot so a live WAL writer cannot wedge this process."""
1012
+ if not path.is_file() or path.is_symlink():
1013
+ raise core.ReaderError(f"ZCode sqlite store is missing: {path}")
1014
+ temp = tempfile.TemporaryDirectory(prefix="dsh-zcode-")
1015
+ snapshot = Path(temp.name) / "db.sqlite"
1016
+ uri = path.resolve().as_uri()
1017
+ try:
1018
+ source = sqlite3.connect(f"{uri}?mode=ro", uri=True, timeout=1.0)
1019
+ try:
1020
+ source.execute("PRAGMA query_only = ON")
1021
+ source.execute("PRAGMA busy_timeout = 1000")
1022
+ dest = sqlite3.connect(snapshot)
1023
+ try:
1024
+ source.backup(dest)
1025
+ finally:
1026
+ dest.close()
1027
+ finally:
1028
+ source.close()
1029
+ database = sqlite3.connect(f"{snapshot.resolve().as_uri()}?mode=ro", uri=True)
1030
+ except (OSError, sqlite3.Error):
1031
+ try:
1032
+ shutil.copy2(path, snapshot)
1033
+ for suffix in ("-wal", "-shm"):
1034
+ sibling = Path(f"{path}{suffix}")
1035
+ if sibling.is_file() and not sibling.is_symlink():
1036
+ shutil.copy2(sibling, Path(f"{snapshot}{suffix}"))
1037
+ database = sqlite3.connect(f"{snapshot.resolve().as_uri()}?mode=ro", uri=True)
1038
+ except (OSError, sqlite3.Error):
1039
+ temp.cleanup()
1040
+ temp = None
1041
+ try:
1042
+ database = sqlite3.connect(f"{uri}?mode=ro&immutable=1", uri=True, timeout=1.0)
1043
+ except (OSError, sqlite3.Error) as exc:
1044
+ raise core.ReaderError(f"failed to open ZCode sqlite store {path}: {exc}") from exc
1045
+ database.row_factory = sqlite3.Row
1046
+ try:
1047
+ database.execute("PRAGMA query_only = ON")
1048
+ database.execute("PRAGMA busy_timeout = 1000")
1049
+ except sqlite3.Error as exc:
1050
+ database.close()
1051
+ if temp is not None:
1052
+ temp.cleanup()
1053
+ raise core.ReaderError(f"failed to open ZCode sqlite store {path}: {exc}") from exc
1054
+ return database, temp
1055
+
1056
+
1057
+ @contextmanager
1058
+ def _zcode_database(path: Path) -> Iterator[sqlite3.Connection]:
1059
+ database, temp = _connect_sqlite_snapshot(path)
1060
+ try:
1061
+ yield database
1062
+ finally:
1063
+ database.close()
1064
+ if temp is not None:
1065
+ temp.cleanup()
1066
+
1067
+
1068
+ def _sqlite_tables(database: sqlite3.Connection) -> set[str]:
1069
+ rows = database.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
1070
+ return {str(row[0]) for row in rows}
1071
+
1072
+
1073
+ def _sqlite_columns(database: sqlite3.Connection, table: str) -> set[str]:
1074
+ rows = database.execute(f'PRAGMA table_info("{table}")').fetchall()
1075
+ return {str(row[1]) for row in rows}
1076
+
1077
+
1078
+ def _require_zcode_schema(
1079
+ database: sqlite3.Connection,
1080
+ path: Path,
1081
+ ) -> tuple[dict[str, set[str]], list[dict[str, str]]]:
1082
+ warnings: list[dict[str, str]] = []
1083
+ tables = _sqlite_tables(database)
1084
+ missing_tables = [name for name in ("session", "message", "part") if name not in tables]
1085
+ if missing_tables:
1086
+ raise core.ReaderError(
1087
+ f"ZCode database {path} is missing required table(s): {', '.join(missing_tables)}"
1088
+ )
1089
+ columns: dict[str, set[str]] = {}
1090
+ for table, required in _ZCODE_REQUIRED_COLUMNS.items():
1091
+ present = _sqlite_columns(database, table)
1092
+ missing = [name for name in required if name not in present]
1093
+ if missing:
1094
+ raise core.ReaderError(
1095
+ f"ZCode table {table} in {path} is missing required column(s): {', '.join(missing)}"
1096
+ )
1097
+ columns[table] = present
1098
+ if "task_type" not in columns["session"]:
1099
+ core._add_warning(
1100
+ warnings,
1101
+ "task_type_unavailable",
1102
+ "ZCode session table has no task_type column; subagent_child sessions could not be filtered.",
1103
+ )
1104
+ return columns, warnings
1105
+
1106
+
1107
+ def _selected_columns(present: set[str], required: Iterable[str], optional: Iterable[str]) -> list[str]:
1108
+ names = [name for name in required if name in present]
1109
+ names.extend(name for name in optional if name in present and name not in names)
1110
+ return names
1111
+
1112
+
1113
+ def _session_payload(row: sqlite3.Row) -> dict[str, Any]:
1114
+ return _parse_data_object(_row_value(row, "data")) or {}
1115
+
1116
+
1117
+ def _session_text(row: sqlite3.Row, payload: dict[str, Any], name: str, *aliases: str) -> str | None:
1118
+ value = _row_value(row, name)
1119
+ if isinstance(value, str) and value.strip():
1120
+ return value
1121
+ for key in (name, *aliases):
1122
+ candidate = payload.get(key)
1123
+ if isinstance(candidate, str) and candidate.strip():
1124
+ return candidate
1125
+ return None
1126
+
1127
+
1128
+ def _zcode_tool_part(
1129
+ payload: dict[str, Any],
1130
+ max_tool_chars: int,
1131
+ index: int,
1132
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
1133
+ state = payload.get("state") if isinstance(payload.get("state"), dict) else {}
1134
+ call_id = (
1135
+ payload.get("callID")
1136
+ or payload.get("callId")
1137
+ or payload.get("toolCallId")
1138
+ or payload.get("id")
1139
+ or f"zcode-call-{index}"
1140
+ )
1141
+ name = payload.get("tool") or payload.get("name") or state.get("tool") or "zcode_tool"
1142
+ raw_input = state.get("input") if "input" in state else payload.get("input", {})
1143
+ call = {
1144
+ "id": core._safe_text(call_id),
1145
+ "name": core._safe_text(name),
1146
+ "input": core._json_preview(raw_input, max_tool_chars),
1147
+ "inert": True,
1148
+ }
1149
+ status = state.get("status")
1150
+ has_output = "output" in state or "error" in state
1151
+ if status in {None, "pending", "running"} and not has_output:
1152
+ return call, None
1153
+ is_error = status in {"error", "failed"}
1154
+ if is_error and state.get("error") not in (None, ""):
1155
+ output: Any = state.get("error")
1156
+ elif "output" in state:
1157
+ output = state.get("output")
1158
+ elif "error" in state:
1159
+ output = state.get("error")
1160
+ is_error = True
1161
+ else:
1162
+ output = ""
1163
+ content = _bounded_text(output, max_tool_chars) if isinstance(output, str) else core._json_preview(output, max_tool_chars)
1164
+ return call, {
1165
+ "tool_use_id": core._safe_text(call_id),
1166
+ "content": content,
1167
+ "is_error": is_error,
1168
+ "unavailable": False,
1169
+ "inert": True,
1170
+ }
1171
+
1172
+
1173
+ def _compaction_summary(payload: dict[str, Any]) -> str:
1174
+ for key in ("summary", "text", "content"):
1175
+ value = payload.get(key)
1176
+ if isinstance(value, str) and value.strip():
1177
+ return core._safe_text(value)
1178
+ details: list[str] = []
1179
+ tail = payload.get("tail_start_id") or payload.get("tailStartId")
1180
+ summary_id = payload.get("summaryMessageId") or payload.get("summary_message_id")
1181
+ if isinstance(tail, str) and tail:
1182
+ details.append(f"tail {tail}")
1183
+ if isinstance(summary_id, str) and summary_id:
1184
+ details.append(f"summary message {summary_id}")
1185
+ if isinstance(payload.get("compactBoundary"), dict):
1186
+ details.append("compact boundary present")
1187
+ detail = "; ".join(details) if details else "boundary present"
1188
+ return f"ZCode context was compacted ({detail}). Older rows still in the database were kept."
1189
+
1190
+
1191
+ def _message_model(payload: dict[str, Any]) -> str | None:
1192
+ for key in ("modelID", "modelId"):
1193
+ value = payload.get(key)
1194
+ if isinstance(value, str) and value.strip():
1195
+ return value
1196
+ model = payload.get("model")
1197
+ if isinstance(model, str) and model.strip():
1198
+ return model
1199
+ if isinstance(model, dict):
1200
+ for key in ("modelID", "modelId", "id"):
1201
+ value = model.get(key)
1202
+ if isinstance(value, str) and value.strip():
1203
+ return value
1204
+ return None
1205
+
1206
+
1207
+ def _assemble_zcode_turns(
1208
+ messages: list[tuple[str, dict[str, Any], list[dict[str, Any]]]],
1209
+ max_tool_chars: int,
1210
+ warnings: list[dict[str, str]],
1211
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None, int]:
1212
+ """Build inert turns. Compaction markers never drop rows that still exist."""
1213
+ turns: list[dict[str, Any]] = []
1214
+ summaries: list[dict[str, Any]] = []
1215
+ hidden = 0
1216
+ unknown = 0
1217
+ malformed = 0
1218
+ compactions = 0
1219
+ model: str | None = None
1220
+ for message_index, (role, payload, parts) in enumerate(messages):
1221
+ if role == "assistant":
1222
+ found = _message_model(payload)
1223
+ if found:
1224
+ model = found
1225
+ texts: list[str] = []
1226
+ calls: list[dict[str, Any]] = []
1227
+ results: list[dict[str, Any]] = []
1228
+ for part_index, part in enumerate(parts):
1229
+ part_type = part.get("type")
1230
+ if part_type == "text":
1231
+ text = part.get("text")
1232
+ if isinstance(text, str) and text:
1233
+ texts.append(core._safe_text(text))
1234
+ continue
1235
+ if part_type in _ZCODE_HIDDEN_PARTS:
1236
+ hidden += 1
1237
+ continue
1238
+ if part_type == "tool":
1239
+ call, result = _zcode_tool_part(part, max_tool_chars, message_index * 1000 + part_index)
1240
+ calls.append(call)
1241
+ if result is not None:
1242
+ results.append(result)
1243
+ continue
1244
+ if part_type == "compaction":
1245
+ compactions += 1
1246
+ summaries.append(
1247
+ {
1248
+ "kind": "compaction",
1249
+ "content": _compaction_summary(part),
1250
+ "inert": True,
1251
+ }
1252
+ )
1253
+ continue
1254
+ if part_type in _ZCODE_STRUCTURAL_PARTS:
1255
+ continue
1256
+ unknown += 1
1257
+ text = "\n".join(texts)
1258
+ if text or calls:
1259
+ turns.append(core._turn(role, text=text, tool_calls=calls))
1260
+ if results:
1261
+ turns.append(core._turn("tool", tool_results=results))
1262
+ if malformed:
1263
+ core._add_warning(
1264
+ warnings,
1265
+ "malformed_records_skipped",
1266
+ f"Skipped {malformed} malformed ZCode record(s).",
1267
+ )
1268
+ if hidden:
1269
+ core._add_warning(
1270
+ warnings,
1271
+ "hidden_reasoning_skipped",
1272
+ f"Excluded {hidden} ZCode reasoning or thinking part(s).",
1273
+ )
1274
+ if compactions:
1275
+ core._add_warning(
1276
+ warnings,
1277
+ "compaction_summary",
1278
+ (
1279
+ f"Surfaced {compactions} ZCode compaction marker(s) as summary markers. "
1280
+ "Older rows still present in the database were kept."
1281
+ ),
1282
+ )
1283
+ if unknown:
1284
+ core._add_warning(
1285
+ warnings,
1286
+ "unknown_records_skipped",
1287
+ f"Skipped {unknown} unsupported ZCode part(s).",
1288
+ )
1289
+ return turns, summaries, model, malformed
1290
+
1291
+
1292
+ class ZCodeAdapter(ProviderAdapter):
1293
+ """Read ZCode's OpenCode-like sqlite store as inert history.
1294
+
1295
+ Primary store: ``$ZCODE_HOME/cli/db/db.sqlite`` or ``~/.zcode/cli/db/db.sqlite``.
1296
+ ``session.directory`` scopes discovery to the current cwd. ``task_type ==
1297
+ subagent_child`` is omitted from discovery; an explicit native id can still
1298
+ be read. Abandoned ``v2/sessions`` and ``cli/rollout`` JSONL are never a
1299
+ source. ``projects/**/*.jsonl`` is a warned best-effort fallback only when
1300
+ the sqlite file is absent.
1301
+ """
1302
+
1303
+ tool = "zcode"
1304
+
1305
+ @staticmethod
1306
+ def _home() -> Path:
1307
+ configured = os.environ.get("ZCODE_HOME")
1308
+ return Path(configured).expanduser() if configured else Path.home() / ".zcode"
1309
+
1310
+ @classmethod
1311
+ def _database_path(cls) -> Path:
1312
+ return cls._home() / "cli" / "db" / "db.sqlite"
1313
+
1314
+ @classmethod
1315
+ def _projects_root(cls) -> Path:
1316
+ return cls._home() / "projects"
1317
+
1318
+ def _candidate(
1319
+ self,
1320
+ *,
1321
+ session_id: str,
1322
+ path: Path,
1323
+ title: str | None,
1324
+ cwd: str | None,
1325
+ created_ms: int | None,
1326
+ updated_ms: int,
1327
+ task_type: str | None,
1328
+ parent_id: str | None,
1329
+ store: str,
1330
+ model: str | None = None,
1331
+ ) -> dict[str, Any]:
1332
+ source = "zcode" if store == "sqlite" else "zcode-projects-jsonl"
1333
+ return {
1334
+ "tool": "zcode",
1335
+ "source": source,
1336
+ "store": store,
1337
+ "session_id": core._safe_text(session_id),
1338
+ "path": str(path),
1339
+ "title": core._safe_text(title) if title else None,
1340
+ "cwd": core._safe_text(cwd) if cwd else None,
1341
+ "branch": None,
1342
+ "created_at": core._iso_from_millis(created_ms),
1343
+ "updated_at_ms": updated_ms,
1344
+ "updated_at": core._iso_from_millis(updated_ms),
1345
+ "source_repo_root_path": None,
1346
+ "model": core._safe_text(model) if model else None,
1347
+ "task_type": core._safe_text(task_type) if task_type else None,
1348
+ "parent_session": core._safe_text(parent_id) if parent_id else None,
1349
+ }
1350
+
1351
+ def _sqlite_candidates(
1352
+ self,
1353
+ database: sqlite3.Connection,
1354
+ path: Path,
1355
+ columns: dict[str, set[str]],
1356
+ *,
1357
+ include_subagents: bool,
1358
+ ) -> list[dict[str, Any]]:
1359
+ selected = _selected_columns(
1360
+ columns["session"],
1361
+ _ZCODE_REQUIRED_COLUMNS["session"],
1362
+ _ZCODE_SESSION_OPTIONAL,
1363
+ )
1364
+ rows = database.execute(
1365
+ f"SELECT {', '.join(selected)} FROM session ORDER BY time_updated, id"
1366
+ ).fetchall()
1367
+ sessions: list[dict[str, Any]] = []
1368
+ for row in rows:
1369
+ payload = _session_payload(row)
1370
+ task_type = _session_text(row, payload, "task_type", "taskType")
1371
+ if not include_subagents and task_type == "subagent_child":
1372
+ continue
1373
+ session_id = _row_value(row, "id")
1374
+ directory = _session_text(row, payload, "directory", "cwd", "workspace")
1375
+ if not isinstance(session_id, str) or not session_id:
1376
+ continue
1377
+ created_ms = core._timestamp_to_millis(_row_value(row, "time_created"))
1378
+ updated_ms = core._timestamp_to_millis(_row_value(row, "time_updated")) or created_ms or 0
1379
+ sessions.append(
1380
+ self._candidate(
1381
+ session_id=session_id,
1382
+ path=path,
1383
+ title=_session_text(row, payload, "title"),
1384
+ cwd=directory,
1385
+ created_ms=created_ms,
1386
+ updated_ms=updated_ms,
1387
+ task_type=task_type,
1388
+ parent_id=_session_text(row, payload, "parent_id", "parentId"),
1389
+ store="sqlite",
1390
+ )
1391
+ )
1392
+ return sessions
1393
+
1394
+ def _iter_project_jsonl(self) -> Iterable[Path]:
1395
+ root = self._projects_root()
1396
+ if not root.is_dir() or root.is_symlink():
1397
+ return
1398
+ stack = [root]
1399
+ while stack:
1400
+ current = stack.pop()
1401
+ try:
1402
+ children = sorted(current.iterdir(), key=lambda item: item.name)
1403
+ except OSError:
1404
+ continue
1405
+ for child in children:
1406
+ if child.is_symlink() or _is_abandoned_zcode_path(child):
1407
+ continue
1408
+ if child.is_dir():
1409
+ if len(child.relative_to(root).parts) <= 3:
1410
+ stack.append(child)
1411
+ continue
1412
+ if child.is_file() and child.suffix == ".jsonl":
1413
+ yield child
1414
+
1415
+ def _jsonl_file_candidates(
1416
+ self,
1417
+ path: Path,
1418
+ cwd: str,
1419
+ *,
1420
+ require_cwd: bool,
1421
+ ) -> list[dict[str, Any]]:
1422
+ records, _malformed = core._read_plain_jsonl(path)
1423
+ groups: dict[str, list[dict[str, Any]]] = {}
1424
+ order: list[str] = []
1425
+ for record in records:
1426
+ session_id = record.get("sessionId") or record.get("session_id")
1427
+ if record.get("type") == "session" and isinstance(record.get("id"), str):
1428
+ session_id = record["id"]
1429
+ if not isinstance(session_id, str) or not session_id.strip():
1430
+ session_id = path.stem
1431
+ if session_id not in groups:
1432
+ groups[session_id] = []
1433
+ order.append(session_id)
1434
+ groups[session_id].append(record)
1435
+ candidates: list[dict[str, Any]] = []
1436
+ for session_id in order:
1437
+ group = groups[session_id]
1438
+ actual_cwd = next(
1439
+ (
1440
+ value
1441
+ for record in group
1442
+ for value in (record.get("cwd"), record.get("directory"))
1443
+ if isinstance(value, str) and value.strip()
1444
+ ),
1445
+ None,
1446
+ )
1447
+ if require_cwd and not _same_cwd(actual_cwd, cwd):
1448
+ continue
1449
+ if not any(record.get("role") in {"user", "assistant"} for record in group):
1450
+ continue
1451
+ title = next(
1452
+ (
1453
+ record.get("title")
1454
+ for record in group
1455
+ if isinstance(record.get("title"), str) and record["title"].strip()
1456
+ ),
1457
+ None,
1458
+ )
1459
+ timestamps = [
1460
+ core._timestamp_to_millis(record.get("timestamp") or record.get("time"))
1461
+ for record in group
1462
+ ]
1463
+ valid = [value for value in timestamps if value is not None]
1464
+ updated_ms = valid[-1] if valid else core._mtime_millis(path)
1465
+ candidates.append(
1466
+ self._candidate(
1467
+ session_id=session_id,
1468
+ path=path,
1469
+ title=title if isinstance(title, str) else None,
1470
+ cwd=actual_cwd,
1471
+ created_ms=valid[0] if valid else None,
1472
+ updated_ms=updated_ms,
1473
+ task_type=None,
1474
+ parent_id=None,
1475
+ store="projects-jsonl",
1476
+ )
1477
+ )
1478
+ return candidates
1479
+
1480
+ def _discover_sqlite(self, path: Path, cwd: str, within_min: int) -> list[dict[str, Any]]:
1481
+ with _zcode_database(path) as database:
1482
+ columns, warnings = _require_zcode_schema(database, path)
1483
+ self.warnings.extend(warnings)
1484
+ sessions = [
1485
+ candidate
1486
+ for candidate in self._sqlite_candidates(
1487
+ database,
1488
+ path,
1489
+ columns,
1490
+ include_subagents=False,
1491
+ )
1492
+ if _same_cwd(candidate.get("cwd"), cwd)
1493
+ and core._within(int(candidate.get("updated_at_ms") or 0), within_min)
1494
+ ]
1495
+ return core._sort_and_dedupe(sessions)
1496
+
1497
+ def _discover_jsonl(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
1498
+ sessions: list[dict[str, Any]] = []
1499
+ saw_file = False
1500
+ for path in self._iter_project_jsonl():
1501
+ saw_file = True
1502
+ try:
1503
+ found = self._jsonl_file_candidates(path, cwd, require_cwd=True)
1504
+ except core.ReaderError as exc:
1505
+ core._add_warning(
1506
+ self.warnings,
1507
+ "jsonl_fallback",
1508
+ f"Skipped unreadable ZCode projects JSONL {path}: {exc}",
1509
+ )
1510
+ continue
1511
+ for candidate in found:
1512
+ if core._within(int(candidate.get("updated_at_ms") or 0), within_min):
1513
+ sessions.append(candidate)
1514
+ if saw_file:
1515
+ core._add_warning(self.warnings, "jsonl_fallback", _ZCODE_JSONL_FALLBACK_WARNING)
1516
+ return core._sort_and_dedupe(sessions)
1517
+
1518
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
1519
+ self.warnings = []
1520
+ path = self._database_path()
1521
+ if path.is_symlink():
1522
+ core._add_warning(
1523
+ self.warnings,
1524
+ "sqlite_store_skipped",
1525
+ f"ZCode sqlite path is a symlink and was not followed: {path}",
1526
+ )
1527
+ elif path.is_file():
1528
+ return self._discover_sqlite(path, cwd, within_min)
1529
+ return self._discover_jsonl(cwd, within_min)
1530
+
1531
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
1532
+ path = Path(raw_path).expanduser()
1533
+ if _is_abandoned_zcode_path(path):
1534
+ return None
1535
+ if not path.is_file() or path.is_symlink() or path.suffix != ".jsonl":
1536
+ return None
1537
+ try:
1538
+ found = self._jsonl_file_candidates(path, cwd, require_cwd=False)
1539
+ except core.ReaderError:
1540
+ return None
1541
+ if len(found) == 1:
1542
+ return found[0]
1543
+ if len(found) > 1:
1544
+ raise core.AmbiguousReference(raw_path, found)
1545
+ return None
1546
+
1547
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
1548
+ path = self._database_path()
1549
+ if path.is_file() and not path.is_symlink():
1550
+ with _zcode_database(path) as database:
1551
+ columns, _warnings = _require_zcode_schema(database, path)
1552
+ matches = [
1553
+ candidate
1554
+ for candidate in self._sqlite_candidates(
1555
+ database,
1556
+ path,
1557
+ columns,
1558
+ include_subagents=True,
1559
+ )
1560
+ if str(candidate.get("session_id", "")).casefold() == session_id.casefold()
1561
+ ]
1562
+ if len(matches) == 1:
1563
+ return matches[0]
1564
+ if len(matches) > 1:
1565
+ raise core.AmbiguousReference(session_id, matches)
1566
+ return None
1567
+ matches = [
1568
+ candidate
1569
+ for candidate in self._discover_jsonl(cwd, 0)
1570
+ if str(candidate.get("session_id", "")).casefold() == session_id.casefold()
1571
+ ]
1572
+ if len(matches) == 1:
1573
+ return matches[0]
1574
+ if len(matches) > 1:
1575
+ raise core.AmbiguousReference(session_id, matches)
1576
+ return None
1577
+
1578
+ def _load_sqlite_parts(
1579
+ self,
1580
+ database: sqlite3.Connection,
1581
+ columns: dict[str, set[str]],
1582
+ session_id: str,
1583
+ ) -> tuple[list[tuple[str, dict[str, Any], list[dict[str, Any]]]], int]:
1584
+ message_cols = _selected_columns(columns["message"], ("id", "session_id", "data"), ("time_created",))
1585
+ message_order = "time_created, id" if "time_created" in columns["message"] else "rowid"
1586
+ message_rows = database.execute(
1587
+ f"SELECT {', '.join(message_cols)} FROM message WHERE session_id = ? ORDER BY {message_order}",
1588
+ (session_id,),
1589
+ ).fetchall()
1590
+ part_optional = ("id", "session_id", "time_created")
1591
+ part_cols = _selected_columns(columns["part"], ("message_id", "data"), part_optional)
1592
+ part_order = "time_created, rowid" if "time_created" in columns["part"] else "rowid"
1593
+ if "session_id" in columns["part"]:
1594
+ part_rows = database.execute(
1595
+ f"SELECT {', '.join(part_cols)} FROM part WHERE session_id = ? ORDER BY {part_order}",
1596
+ (session_id,),
1597
+ ).fetchall()
1598
+ else:
1599
+ part_rows = database.execute(
1600
+ f"SELECT {', '.join(part_cols)} FROM part WHERE message_id IN "
1601
+ "(SELECT id FROM message WHERE session_id = ?) "
1602
+ f"ORDER BY {part_order}",
1603
+ (session_id,),
1604
+ ).fetchall()
1605
+ parts_by_message: dict[str, list[dict[str, Any]]] = {}
1606
+ malformed = 0
1607
+ for row in part_rows:
1608
+ message_id = _row_value(row, "message_id")
1609
+ payload = _parse_data_object(_row_value(row, "data"))
1610
+ if not isinstance(message_id, str) or payload is None:
1611
+ malformed += 1
1612
+ continue
1613
+ parts_by_message.setdefault(message_id, []).append(payload)
1614
+ messages: list[tuple[str, dict[str, Any], list[dict[str, Any]]]] = []
1615
+ for row in message_rows:
1616
+ message_id = _row_value(row, "id")
1617
+ payload = _parse_data_object(_row_value(row, "data"))
1618
+ if not isinstance(message_id, str) or payload is None:
1619
+ malformed += 1
1620
+ continue
1621
+ role = payload.get("role")
1622
+ if role not in {"user", "assistant"}:
1623
+ malformed += 1
1624
+ continue
1625
+ messages.append((role, payload, parts_by_message.get(message_id, [])))
1626
+ return messages, malformed
1627
+
1628
+ def _read_sqlite(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
1629
+ path = Path(str(candidate["path"]))
1630
+ session_id = str(candidate.get("session_id") or "")
1631
+ with _zcode_database(path) as database:
1632
+ columns, schema_warnings = _require_zcode_schema(database, path)
1633
+ messages, malformed = self._load_sqlite_parts(database, columns, session_id)
1634
+ warnings = list(schema_warnings)
1635
+ if malformed:
1636
+ core._add_warning(
1637
+ warnings,
1638
+ "malformed_records_skipped",
1639
+ f"Skipped {malformed} malformed ZCode message or part row(s).",
1640
+ )
1641
+ turns, summaries, model, _ignored = _assemble_zcode_turns(messages, max_tool_chars, warnings)
1642
+ if not candidate.get("title"):
1643
+ title = next(
1644
+ (
1645
+ core._one_line(turn.get("text"), 120)
1646
+ for turn in turns
1647
+ if turn.get("role") == "user" and turn.get("text")
1648
+ ),
1649
+ None,
1650
+ )
1651
+ else:
1652
+ title = candidate.get("title")
1653
+ result = {
1654
+ "tool": "zcode",
1655
+ "source": "zcode",
1656
+ "session_id": session_id,
1657
+ "path": str(path),
1658
+ "title": title,
1659
+ "cwd": candidate.get("cwd"),
1660
+ "branch": None,
1661
+ "created_at": candidate.get("created_at"),
1662
+ "updated_at": candidate.get("updated_at"),
1663
+ "source_repo_root_path": None,
1664
+ "model": model or candidate.get("model"),
1665
+ "task_type": candidate.get("task_type"),
1666
+ "parent_session": candidate.get("parent_session"),
1667
+ "turns": turns,
1668
+ "summaries": summaries,
1669
+ "warnings": warnings,
1670
+ }
1671
+ return core._finalize_result(result)
1672
+
1673
+ def _read_jsonl(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
1674
+ path = Path(str(candidate["path"]))
1675
+ records, malformed = core._read_plain_jsonl(path)
1676
+ wanted = str(candidate.get("session_id") or "")
1677
+ messages: list[tuple[str, dict[str, Any], list[dict[str, Any]]]] = []
1678
+ model: str | None = None
1679
+ for record in records:
1680
+ session_id = record.get("sessionId") or record.get("session_id") or path.stem
1681
+ if record.get("type") == "session":
1682
+ header_id = record.get("id")
1683
+ if isinstance(header_id, str):
1684
+ session_id = header_id
1685
+ if str(session_id).casefold() != wanted.casefold():
1686
+ continue
1687
+ if record.get("type") == "session":
1688
+ continue
1689
+ role = record.get("role")
1690
+ if role not in {"user", "assistant"}:
1691
+ continue
1692
+ content = record.get("content", record.get("text"))
1693
+ parts: list[dict[str, Any]] = []
1694
+ if isinstance(content, str):
1695
+ parts.append({"type": "text", "text": content})
1696
+ elif isinstance(content, list):
1697
+ for block in content:
1698
+ if isinstance(block, dict):
1699
+ parts.append(block)
1700
+ elif content is not None:
1701
+ parts.append({"type": "text", "text": core._safe_text(content)})
1702
+ found = _message_model(record)
1703
+ payload = {"role": role}
1704
+ if found:
1705
+ payload["modelID"] = found
1706
+ model = found
1707
+ messages.append((role, payload, parts))
1708
+ warnings: list[dict[str, str]] = []
1709
+ core._add_warning(warnings, "jsonl_fallback", _ZCODE_JSONL_FALLBACK_WARNING)
1710
+ if malformed:
1711
+ core._add_warning(
1712
+ warnings,
1713
+ "malformed_records_skipped",
1714
+ f"Skipped {malformed} malformed ZCode JSONL record(s).",
1715
+ )
1716
+ turns, summaries, assembled_model, _ignored = _assemble_zcode_turns(messages, max_tool_chars, warnings)
1717
+ result = {
1718
+ "tool": "zcode",
1719
+ "source": "zcode-projects-jsonl",
1720
+ "session_id": wanted,
1721
+ "path": str(path),
1722
+ "title": candidate.get("title"),
1723
+ "cwd": candidate.get("cwd"),
1724
+ "branch": None,
1725
+ "created_at": candidate.get("created_at"),
1726
+ "updated_at": candidate.get("updated_at"),
1727
+ "source_repo_root_path": None,
1728
+ "model": assembled_model or model,
1729
+ "task_type": None,
1730
+ "turns": turns,
1731
+ "summaries": summaries,
1732
+ "warnings": warnings,
1733
+ }
1734
+ return core._finalize_result(result)
1735
+
1736
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
1737
+ if candidate.get("store") == "projects-jsonl":
1738
+ return self._read_jsonl(candidate, max_tool_chars)
1739
+ return self._read_sqlite(candidate, max_tool_chars)
1740
+
1741
+
959
1742
  ADAPTERS: dict[str, ProviderAdapter] = {
960
1743
  "grok": GrokAdapter(),
961
1744
  "pi": PiAdapter(),
1745
+ "zcode": ZCodeAdapter(),
962
1746
  }
963
1747
 
964
1748
 
@@ -1070,7 +1854,7 @@ def main(argv: list[str] | None = None) -> int:
1070
1854
  "tool": args.tool,
1071
1855
  "cwd": args.cwd,
1072
1856
  "sessions": sessions,
1073
- "warnings": [],
1857
+ "warnings": list(adapter.warnings),
1074
1858
  },
1075
1859
  indent=2,
1076
1860
  ensure_ascii=True,