@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 +33 -11
- package/README.md +53 -11
- package/lib/dsh-resume.js +12 -4
- package/lib/types/dsh-resume.d.ts +1 -1
- package/lib/types/reader.d.ts +1 -1
- package/lib/types/skills.d.ts +7 -1
- package/package.json +23 -18
- package/resources/dsh_session_reader.py +788 -4
package/README.en.md
CHANGED
|
@@ -2,28 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](README.md)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
19
|
+
### Web Host
|
|
10
20
|
|
|
11
|
-
|
|
21
|
+
Use the official CLI:
|
|
12
22
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
+
## Usage
|
|
25
46
|
|
|
26
|
-
Type `/resume-claude`, or the matching command for the other
|
|
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
|

|
|
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
|
-
#
|
|
1
|
+
# npm installation for DSH 0.2.0-rc.2
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
14
|
+
dsh plugin --profile web add dsh-resume@npm:@aa2246740/dsh-resume@0.2.5
|
|
7
15
|
```
|
|
8
16
|
|
|
9
|
-
|
|
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
|
-
|
|
29
|
+
**0.2.5 面向 DeepSeek Harness 0.2.0-rc.2 验证。** 安装包和仓库均包含编译好的插件,无需下载 Harness 源码或自行构建。读取历史需要本机有 Python 3;读取压缩的 Codex rollout 另需 `zstd`。
|
|
12
30
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
+
## 使用
|
|
25
66
|
|
|
26
|
-
输入 `/resume-claude
|
|
67
|
+
输入 `/resume-claude`,另外五条同理。它只读本机那次会话,抽出还能接手的上下文,写成一张交接卡。旧进程不会被拉起来,别人的会话文件也不会被改。
|
|
27
68
|
|
|
28
69
|

|
|
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
|
|
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
|
|
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
|
|
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
|
|
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';
|
package/lib/types/reader.d.ts
CHANGED
|
@@ -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];
|
package/lib/types/skills.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
4
|
-
"description": "Continue Codex, Claude Code, Cursor, Grok, and
|
|
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.
|
|
60
|
-
"@deepseek-ai/dsh-tools": ">=0.
|
|
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.
|
|
65
|
-
"@deepseek-ai/dsh-invariants": "0.
|
|
66
|
-
"@deepseek-ai/dsh-llm": "0.
|
|
67
|
-
"@deepseek-ai/dsh-ptc-runtime": "0.
|
|
68
|
-
"@deepseek-ai/dsh-sandbox": "0.
|
|
69
|
-
"@deepseek-ai/dsh-sandbox-policy": "0.
|
|
70
|
-
"@deepseek-ai/dsh-scope": "0.
|
|
71
|
-
"@deepseek-ai/dsh-session": "0.
|
|
72
|
-
"@deepseek-ai/dsh-session-projection": "0.
|
|
73
|
-
"@deepseek-ai/dsh-skill": "0.
|
|
74
|
-
"@deepseek-ai/dsh-system-prompt": "0.
|
|
75
|
-
"@deepseek-ai/dsh-tools": "0.
|
|
76
|
-
"@deepseek-ai/dsh-user-approval": "0.
|
|
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
|
|
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,
|