diff --git a/README.md b/README.md index 269380d..a153b3a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Sources in this repository: | **pi.log-viewer** | Large log viewer with streaming pagination, live tail, search highlighting and multi-file tabs | Tioit-Wang | | **pi.file-manager** | Project file manager in the right work panel: directory tree, syntax-highlighted editor, Markdown preview, media and CSV/JSON views, read-only SQLite browser, context-menu file operations, filename search | Tioit-Wang | | **pi.bianqian** | Markdown desktop sticky notes: multi-note, live preview, task lists, highlighter and trash | ZY | -| **io.github.muzimu217.session-import** | Universal session import + forge: bring sessions in from ZCode, WorkBuddy, Claude Code, Codex, OpenCode and Pi, then distill them into project conventions and reusable practices | muzimu217 | +| **io.github.muzimu217.session-import** | Moved to its own repository: [muzimu217/pi-desktop-session-import](https://github.com/muzimu217/pi-desktop-session-import), published on the [plugin center](https://plugins.aiuo.net/) | muzimu217 | | **io.github.muzimu217.deps-audit** | Dependency vulnerability audit: runs osv-scanner over the workspace, lists known OSV matches and lets the Agent propose a fix or upgrade | muzimu217 | | **io.github.liushunqiu.pi-idea-git** | IDEA-style Git tool window: staged/unstaged change lists, hunk-level stage & revert, commit, branch switcher, graph log and stashes | liushunqiu | | **pi.workspace-file-guard** | Keeps model-written test, log, cache and temp files off the system drive, Desktop and Downloads; junk stays in the project's Temp or scratch | xingleiwu | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3c639be..73fb18a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,7 +44,7 @@ | **pi.log-viewer** | 大日志查看器:流式分页、实时跟随、搜索高亮、多文件页签 | Tioit-Wang | | **pi.file-manager** | 文件管理器:目录树、代码高亮编辑、Markdown 预览、图片音视频与 CSV/JSON 查看、SQLite 只读浏览与 SQL 查询、右键文件操作、按文件名搜索 | Tioit-Wang | | **pi.bianqian** | Markdown 桌面便签:多便签、实时预览、任务列表、荧光笔与回收站 | ZY | -| **io.github.muzimu217.session-import** | 一体化会话导入与熔炉:导入 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 的会话,再蒸馏成项目约定与可复用做法 | muzimu217 | +| **io.github.muzimu217.session-import** | 已迁移至独立仓库:[muzimu217/pi-desktop-session-import](https://github.com/muzimu217/pi-desktop-session-import),发布于[插件中心](https://plugins.aiuo.net/) | 一体化会话导入与熔炉:导入 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 的会话,再蒸馏成项目约定与可复用做法 | muzimu217 | | **io.github.muzimu217.deps-audit** | 依赖漏洞扫描:唤起 osv-scanner 扫描工作区,列出 OSV 依赖漏洞,让 Agent 给出升级或修复 patch | muzimu217 | | **io.github.liushunqiu.pi-idea-git** | IDEA 风格 Git 工具窗口:暂存/未暂存分组、按代码块暂存与还原、提交、分支切换、图形化日志与储藏 | liushunqiu | | **pi.workspace-file-guard** | C盘防垃圾:防止模型把测试、日志、缓存、临时文件写到系统盘、桌面、下载,垃圾只待在当前项目的 Temp 或 scratch | xingleiwu | diff --git a/catalog.json b/catalog.json index a9ce7f6..79dfc14 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-20T15:44:14Z", + "updatedAt": "2026-09-20T17:49:14Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -275,78 +275,6 @@ } ] }, - { - "id": "io.github.muzimu217.session-import", - "name": "Universal Session Import", - "description": "\u4e00\u4f53\u5316\u4f1a\u8bdd\u5bfc\u5165\u4e0e\u7194\u7089\uff1a\u626b\u63cf\u672c\u673a\u7684 ZCode\u3001WorkBuddy\u3001Claude Code\u3001Codex\u3001OpenCode\u3001Pi \u5e76\u5bfc\u5165\u4f1a\u8bdd\uff1b\u53ef\u7528\u58f0\u660e\u5f0f JSON \u81ea\u5b9a\u4e49\u6765\u6e90\u6269\u5c55\uff1b\u518d\u8bfb\u56de\u8fd9\u4e9b\u4f1a\u8bdd\uff0c\u7528\u5bbf\u4e3b\u989d\u5ea6\u84b8\u998f\u6210\u9879\u76ee\u7ea6\u5b9a\u4e0e\u53ef\u590d\u7528\u505a\u6cd5\u3002", - "i18n": { - "en": { - "name": "Universal Session Import", - "description": "Import sessions from ZCode, WorkBuddy, Claude Code, Codex, OpenCode and Pi into PI-Desktop; extend it with declarative JSON custom sources; then distill those sessions back into project conventions and reusable practices.", - "safetyNotes": "Reads each tool's local session database in read-only mode (Node node:sqlite, readOnly). Writes sessions through the host's session import API. The forge calls the host model using the host's own credentials and quota \u2014 this plugin never sees or stores API keys. fs.write is used only to save distilled output, scoped to AGENTS.md, *.md, docs/** and .agents/skills/** in the workspace. No network requests, no telemetry. Custom source specs (docs/session-import-sources.json in the workspace) are declarative data only \u2014 no code from a config file is ever executed." - }, - "zh-CN": { - "name": "\u4e00\u4f53\u5316\u4f1a\u8bdd\u5bfc\u5165", - "description": "\u4e00\u4f53\u5316\u4f1a\u8bdd\u5bfc\u5165\u4e0e\u7194\u7089\uff1a\u626b\u63cf\u672c\u673a\u7684 ZCode\u3001WorkBuddy\u3001Claude Code\u3001Codex\u3001OpenCode\u3001Pi \u5e76\u5bfc\u5165\u4f1a\u8bdd\uff1b\u53ef\u7528\u58f0\u660e\u5f0f JSON \u81ea\u5b9a\u4e49\u6765\u6e90\u6269\u5c55\uff1b\u518d\u8bfb\u56de\u8fd9\u4e9b\u4f1a\u8bdd\uff0c\u7528\u5bbf\u4e3b\u989d\u5ea6\u84b8\u998f\u6210\u9879\u76ee\u7ea6\u5b9a\u4e0e\u53ef\u590d\u7528\u505a\u6cd5\u3002", - "safetyNotes": "\u4ee5\u53ea\u8bfb\u65b9\u5f0f\u6253\u5f00\u5404\u5de5\u5177\u7684\u672c\u5730\u4f1a\u8bdd\u6570\u636e\u5e93\uff08Node node:sqlite \u7684 readOnly \u6a21\u5f0f\uff09\uff1b\u4f1a\u8bdd\u5199\u5165\u8d70\u5bbf\u4e3b session \u5bfc\u5165\u63a5\u53e3\u3002\u7194\u7089\u8c03\u7528\u5bbf\u4e3b\u6a21\u578b\uff0c\u4f7f\u7528\u5bbf\u4e3b\u81ea\u8eab\u7684\u51ed\u636e\u4e0e\u989d\u5ea6\uff0c\u672c\u63d2\u4ef6\u4e0d\u63a5\u89e6\u4e5f\u4e0d\u4fdd\u5b58\u4efb\u4f55 API Key\u3002fs.write \u4ec5\u7528\u4e8e\u4fdd\u5b58\u84b8\u998f\u7ed3\u679c\uff0c\u8303\u56f4\u9650\u5b9a\u5728\u5de5\u4f5c\u533a\u5185\u7684 AGENTS.md\u3001*.md\u3001docs/**\u3001.agents/skills/**\u3002\u4e0d\u53d1\u8d77\u4efb\u4f55\u7f51\u7edc\u8bf7\u6c42\uff0c\u65e0\u9065\u6d4b\u4e0a\u62a5\u3002 \u81ea\u5b9a\u4e49\u6765\u6e90\u914d\u7f6e\uff08\u5de5\u4f5c\u533a docs/session-import-sources.json\uff09\u53ea\u63a5\u53d7\u7eaf\u6570\u636e\uff0c\u63d2\u4ef6\u6c38\u4e0d\u6267\u884c\u5176\u4e2d\u7684\u4ee3\u7801\u3002" - } - }, - "author": "muzimu217", - "categories": [ - "developer-tools", - "productivity", - "import" - ], - "verified": true, - "downloads": 0, - "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/io.github.muzimu217.session-import", - "repository": "https://github.com/vastsa/pi-desktop-plugins", - "readmeMarkdown": "# Universal Session Import\uff08\u4e00\u4f53\u5316\u4f1a\u8bdd\u5bfc\u5165\uff09\n\nPI-Desktop \u63d2\u4ef6\uff1a**\u626b\u63cf\u672c\u673a\u5b89\u88c5\u7684\u7f16\u7a0b\u5de5\u5177\uff0c\u628a\u5b83\u4eec\uff08ZCode\u3001WorkBuddy\u3001Claude Code\u3001Codex\u3001OpenCode\u3001Pi\uff09\u7684\u672c\u5730\u4f1a\u8bdd\u7edf\u4e00\u5bfc\u5165 PI-Desktop\u3002**\n\n![scan sources](docs/screenshots/01-scan-sources.png)\n\n## \u4f7f\u7528\u6d41\u7a0b\n\n### 1. \u626b\u63cf\u672c\u673a\u5de5\u5177\n\n\u6253\u5f00\u9762\u677f\u70b9\u51fb\u300c\u626b\u63cf\u672c\u673a\u5de5\u5177\u300d\uff0c\u63d2\u4ef6\u5e76\u884c\u63a2\u6d4b\u516d\u4e2a\u6765\u6e90\uff0c\u5b9e\u65f6\u663e\u793a\u5404\u81ea\u68c0\u6d4b\u5230\u7684\u4f1a\u8bdd\u6570\uff08\u672a\u5b89\u88c5\u7684\u5de5\u5177\u663e\u793a\u300c\u672a\u68c0\u6d4b\u5230\u300d\uff09\u3002\n\n### 2. \u9009\u62e9\u6765\u6e90\uff0c\u6d4f\u89c8\u4f1a\u8bdd\n\n\u70b9\u51fb\u68c0\u6d4b\u5230\u7684\u5de5\u5177\u5361\u7247\uff0c\u6309\u9879\u76ee\u5206\u7ec4\u8f7d\u5165\u8be5\u6765\u6e90\u7684\u4f1a\u8bdd\u5217\u8868\uff08\u652f\u6301\u641c\u7d22\u3001\u6574\u7ec4\u5168\u9009\uff09\uff1a\n\n![session list](docs/screenshots/02-session-list.png)\n\n### 3. \u9884\u89c8\u5bf9\u8bdd\n\n\u70b9\u4efb\u610f\u4f1a\u8bdd\u5373\u53ef\u6309\u539f\u59cb\u987a\u5e8f\u8fd8\u539f\u5b8c\u6574\u5bf9\u8bdd\u2014\u2014\u7528\u6237\u6d88\u606f\u3001\u52a9\u624b\u56de\u590d\u3001\u5de5\u5177\u8c03\u7528\uff08\u53c2\u6570 + \u7ed3\u679c\u6298\u53e0\u5c55\u793a\uff0c\u9519\u8bef\u6807\u7ea2\uff09\uff1a\n\n![preview](docs/screenshots/03-preview.png)\n\n### 4. \u5bfc\u5165\u4e3a\u4f1a\u8bdd\n\n\u52fe\u9009\u4f1a\u8bdd\uff08\u652f\u6301\u6574\u7ec4\u5168\u9009\uff09\u2192\u300c\u5bfc\u5165\u4e3a\u4f1a\u8bdd\u300d\uff0c\u901a\u8fc7\u5bbf\u4e3b `session.import` \u901a\u9053\u5199\u5165 PI-Desktop \u4f1a\u8bdd\u5e93\uff1a\n\n![import status](docs/screenshots/04-import-status.png)\n\n\u5bfc\u5165\u7684\u4f1a\u8bdd\u6309**\u539f\u59cb\u5de5\u4f5c\u76ee\u5f55**\u5f52\u5c5e\u9879\u76ee\uff1a\u8be5\u9879\u76ee\u5df2\u5728 PI-Desktop \u6253\u5f00\u65f6**\u7acb\u523b\u5c55\u5f00\u53ef\u89c1**\uff1b\u672a\u6253\u5f00\u7684\u9879\u76ee\u4e5f\u4f1a\u81ea\u52a8\u52a0\u5165\u5de6\u4fa7\u9879\u76ee\u5217\u8868\u5e76\u5c55\u5f00\uff1a\n\n![sidebar auto expand](docs/screenshots/05-sidebar-auto-expand.png)\n\n![sidebar codex](docs/screenshots/06-sidebar-codex.png)\n\n\u91cd\u590d\u5bfc\u5165\u81ea\u52a8\u8df3\u8fc7\uff08\u5e42\u7b49 id\uff1a`import-<\u6765\u6e90>-<\u5916\u90e8\u4f1a\u8bdd id>`\uff09\u3002\n\n## \u6765\u6e90\u4e0e\u7279\u6b8a\u5904\u7406\n\n| \u6765\u6e90 | \u6570\u636e\u4f4d\u7f6e | \u7279\u6b8a\u5904\u7406 |\n| --- | --- | --- |\n| ZCode | `~/.zcode/cli/db/db.sqlite` | `node:sqlite` \u53ea\u8bfb\uff1b\u8fc7\u6ee4 `subagent_child` \u5185\u90e8\u4f1a\u8bdd\u4e0e `synthetic` \u6ce8\u5165\u6587\u672c\uff1b\u5de5\u5177\u8c03\u7528\u6309\u5b58\u50a8\u987a\u5e8f\u8fd8\u539f |\n| WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` | \u5265\u79bb ``/`` \u6ce8\u5165\u5757\uff1b`function_call` \u6309 `callId` \u914d\u5bf9\uff1b\u5916\u7f6e\u5927\u7ed3\u679c\u5b89\u5168\u56de\u8bfb\uff1b`ai-title` \u4f18\u5148 |\n| Claude Code | `~/.claude/projects/**/*.jsonl` | \u8fc7\u6ee4 sidechain \u4e0e\u5408\u6210\u7528\u6237\u884c\uff1b`tool_use`/`tool_result` \u914d\u5bf9 |\n| Codex | `~/.codex/sessions/**/*.jsonl` | \u517c\u5bb9\u65b0\u65e7\u4e24\u79cd\u683c\u5f0f\uff1b\u8fc7\u6ee4 `# AGENTS.md` \u7b49\u5408\u6210\u884c |\n| OpenCode | `%LOCALAPPDATA%\\opencode\\data\\opencode.db`\uff08Windows\uff09/ `~/.local/share/opencode/opencode.db`\uff08macOS/Linux\uff0c\u5c0a\u91cd `XDG_DATA_HOME`\uff09 | `node:sqlite` \u53ea\u8bfb\uff1bsession\u2192message\u2192part \u4e09\u5c42\u8fd8\u539f\uff08OpenCode v1.x SQLite\uff09\uff1b\u591a\u5019\u9009\u8def\u5f84\u6309\u5e8f\u63a2\u6d4b |\n| Pi | `~/.pi/agent/sessions/**/*.jsonl` | `toolCall`/`toolResult` \u914d\u5bf9\uff1b`session_info.name` \u4f18\u5148 |\n\n## \u81ea\u5b9a\u4e49\u6765\u6e90\uff08\u65e0\u9700\u6539\u4ee3\u7801\uff09\n\n\u5185\u7f6e 6 \u4e2a\u6765\u6e90\u4e4b\u5916\uff0c\u4f60\u53ef\u4ee5\u5728**\u5f53\u524d\u5de5\u4f5c\u533a**\u653e\u4e00\u4efd\u7eaf JSON\uff0c\u63cf\u8ff0\u81ea\u5df1\u7684\u5de5\u5177\uff1a\n\n```\ndocs/session-import-sources.json\n```\n\n\u5185\u7f6e\u9a71\u52a8\u8986\u76d6\u4e09\u7c7b\u78c1\u76d8\u683c\u5f0f\uff0c\u7edd\u5927\u591a\u6570\u5de5\u5177\u90fd\u80fd\u843d\u5728\u5176\u4e2d\u4e00\u4e2a\u4e0a\uff1a\n\n| driver | \u9002\u7528 | \u5185\u7f6e\u4f7f\u7528\u8005 |\n| --- | --- | --- |\n| `jsonl-transcript` | \u4e00\u884c\u4e00\u6761\u8bb0\u5f55\u7684 JSONL \u5bf9\u8bdd\u6587\u4ef6 | Claude Code\u3001Codex\u3001WorkBuddy\u3001Pi |\n| `sqlite-session` | SQLite\uff1a`session \u2192 message \u2192 part` \u4e09\u5c42\uff08\u6216 session+message \u4e24\u5c42\uff09 | ZCode\u3001OpenCode |\n| `json-tree` | \u4e00\u4e2a JSON \u6587\u4ef6\u5373\u4e00\u4e2a\u4f1a\u8bdd | \u65e7\u7248 OpenCode / Claude \u5e03\u5c40 |\n\n\u9762\u677f\u300c\u81ea\u5b9a\u4e49\u6765\u6e90 \u2192 \u91cd\u65b0\u52a0\u8f7d\u300d\u5373\u53ef\u70ed\u52a0\u8f7d\uff0c\u65b0\u6765\u6e90\u5e26 `\u81ea\u5b9a\u4e49` \u6807\u7b7e\u51fa\u73b0\u3002\u5b8c\u6574\u5b57\u6bb5\u89c1\n[`docs/CUSTOM-SOURCES.md`](docs/CUSTOM-SOURCES.md)\uff0c\u91cc\u9762\u6709\u53ef\u76f4\u63a5\u590d\u5236\u7684\u4e09\u4e2a driver \u793a\u4f8b\u3002\n\n> **\u5b89\u5168\u8fb9\u754c**\uff1a\u914d\u7f6e**\u53ea\u5141\u8bb8\u7eaf\u6570\u636e**\u3002\u4efb\u4f55 `eval` / `code` / `require` / `transform` / `script`\n> \u4e4b\u7c7b\u7684\u952e\uff08\u4efb\u610f\u5c42\u7ea7\uff09\u90fd\u4f1a\u88ab\u62d2\u7edd\u2014\u2014\u63d2\u4ef6\u5bf9\u7528\u6237\u4e3b\u76ee\u5f55\u6709\u8bfb\u6743\u9650\uff0c\u5141\u8bb8\u914d\u7f6e\u643a\u5e26\u4ee3\u7801\u7b49\u540c\u4e8e\u4efb\u610f\u4ee3\u7801\u6267\u884c\u3002\n> \u6570\u636e\u6839\u76ee\u5f55\u4e5f\u4e0d\u63a5\u53d7 `/` \u6216\u6574\u4e2a home\u3002\n\n## \u6743\u9650\n\n| \u6743\u9650 | \u7528\u9014 |\n| --- | --- |\n| `ui.panel` / `ui.view` | \u5bfc\u5165\u9762\u677f\u4e0e\u300c\u4f1a\u8bdd\u7194\u7089\u300d\u5de5\u4f5c\u9762\u677f\u89c6\u56fe |\n| `session.read` / `session.read.own` | \u8bfb\u56de\u672c\u63d2\u4ef6\u5bfc\u5165\u8fc7\u7684\u4f1a\u8bdd\uff0c\u4f9b\u7194\u7089\u84b8\u998f |\n| `session.import` | \u628a\u9009\u4e2d\u7684\u4f1a\u8bdd\u5199\u5165 PI-Desktop \u4f1a\u8bdd\u5e93 |\n| `project.create` | \u6309\u4f1a\u8bdd\u7684 `projectPath` \u5e42\u7b49\u89e3\u6790 / \u7ed1\u5b9a projectId\uff08\u65e0\u6b64\u80fd\u529b\u7684\u5bbf\u4e3b\u9000\u56de\u672a\u7ed1\u5b9a\u5bfc\u5165\uff09 |\n| `agent.complete` / `models.list` | \u8c03\u7528\u5bbf\u4e3b\u6a21\u578b\u505a\u84b8\u998f\uff08\u4f7f\u7528\u5bbf\u4e3b\u51ed\u636e\u4e0e\u989d\u5ea6\uff0c\u63d2\u4ef6\u4e0d\u63a5\u89e6 API Key\uff09 |\n| `fs.read` | \u53ea\u8bfb\u5404\u5de5\u5177\u7684\u672c\u5730\u4f1a\u8bdd\u6570\u636e\uff08`.jsonl` / SQLite \u7684 `readOnly` \u6a21\u5f0f\uff09\uff1b\u5e76\u8bfb\u53d6\u5de5\u4f5c\u533a `docs/session-import-sources.json` \u91cc\u7684\u81ea\u5b9a\u4e49\u6765\u6e90\u914d\u7f6e |\n| `fs.write` | **\u4ec5**\u7528\u4e8e\u4fdd\u5b58\u84b8\u998f\u7ed3\u679c\uff0c\u8303\u56f4\u9650\u5b9a\u5728\u5de5\u4f5c\u533a\u5185\u7684 `AGENTS.md`\u3001`*.md`\u3001`docs/**`\u3001`.agents/skills/**` |\n\n\u8bfb\u53d6\u5404\u5de5\u5177\u672c\u5730\u6570\u636e\u7531\u63d2\u4ef6\u8fdb\u7a0b**\u53ea\u8bfb**\u5b8c\u6210\uff08`node:sqlite` \u7684 `readOnly` \u6a21\u5f0f\uff0c\u6216\u76f4\u63a5\u8bfb\u53d6\n`.jsonl`\uff09\u3002**\u5bfc\u5165**\u7ecf\u7531\u5bbf\u4e3b `session.import` \u901a\u9053\u5b8c\u6210\uff08\u8be5\u901a\u9053\u4e3a\u63d2\u4ef6\u5bbf\u4e3b\u65b0\u589e\u7684\u5e95\u5c42 API\n\u63d0\u6848\uff0c\u89c1 [PI-Desktop#134](https://github.com/vastsa/PI-Desktop/issues/134)\uff09\u3002\n\n\u63d2\u4ef6\u4e0d\u53d1\u8d77\u4efb\u4f55\u7f51\u7edc\u8bf7\u6c42\u3001\u65e0\u9065\u6d4b\u4e0a\u62a5\uff1b\u7194\u7089\u8c03\u7528\u8d70\u5bbf\u4e3b `pi.agent.complete`\uff0c\u51ed\u636e\u4fdd\u7559\u5728\nElectron \u4e3b\u8fdb\u7a0b\u3002\n\n## \u5f00\u53d1\n\n```bash\n# \u5728 PI-Desktop \u4ed3\u5e93\u5185\u6821\u9a8c\u4e0e\u6253\u5305\npnpm --filter @pi-desktop/plugin-devkit... build\nnode packages/plugin-devkit/dist/cli.js check /absolute/path/to/pi-desktop-session-import\nnode packages/plugin-devkit/dist/cli.js pack /absolute/path/to/pi-desktop-session-import\n```\n\n\u5728 PI-Desktop \u4e2d\uff1a\u63d2\u4ef6\u9875 \u2192 Load development plugin \u2192 \u9009\u62e9\u672c\u76ee\u5f55\u3002\n\n## License\n\nMIT\n", - "safetyNotes": "\u4ee5\u53ea\u8bfb\u65b9\u5f0f\u6253\u5f00\u5404\u5de5\u5177\u7684\u672c\u5730\u4f1a\u8bdd\u6570\u636e\u5e93\uff1b\u4f1a\u8bdd\u5199\u5165\u8d70\u5bbf\u4e3b session \u5bfc\u5165\u63a5\u53e3\u3002\u7194\u7089\u4f7f\u7528\u5bbf\u4e3b\u81ea\u8eab\u989d\u5ea6\u8c03\u7528\u6a21\u578b\uff0c\u63d2\u4ef6\u4e0d\u63a5\u89e6 API Key\u3002fs.write \u4ec5\u7528\u4e8e\u4fdd\u5b58\u84b8\u998f\u7ed3\u679c\uff0c\u8303\u56f4\u9650\u5b9a\u5728\u5de5\u4f5c\u533a\u5185\u7684 AGENTS.md\u3001*.md\u3001docs/**\u3001.agents/skills/**\u3002\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\uff0c\u65e0\u9065\u6d4b\u3002 \u81ea\u5b9a\u4e49\u6765\u6e90\u914d\u7f6e\uff08\u5de5\u4f5c\u533a docs/session-import-sources.json\uff09\u53ea\u63a5\u53d7\u7eaf\u6570\u636e\uff0c\u63d2\u4ef6\u6c38\u4e0d\u6267\u884c\u5176\u4e2d\u7684\u4ee3\u7801\u3002", - "versions": [ - { - "version": "0.4.9", - "publishedAt": "2026-09-20T15:44:14Z", - "changelog": "Release 0.4.9\uff08OpenCode v1.x Windows \u8def\u5f84\u4fee\u590d\uff0c#682\uff09\uff1aOpenCode \u9002\u914d\u5668\u6539\u4e3a\u591a\u5019\u9009\u8def\u5f84\u63a2\u6d4b\u2014\u2014Windows \u5e94\u7528\u6570\u636e\u76ee\u5f55 %LOCALAPPDATA%\\opencode\\data\\\uff08OpenCode \u5b98\u65b9\u786e\u8ba4\u7684\u4f4d\u7f6e\uff09\u3001XDG_DATA_HOME\uff08\u82e5\u8bbe\u7f6e\uff09\u3001\u4ee5\u53ca ~/.local/share/opencode \u9ed8\u8ba4\u4f4d\u7f6e\uff0c\u6309\u5e8f\u63a2\u6d4b\u7b2c\u4e00\u4e2a\u5b58\u5728\u7684\u5e93\u3002\u6b64\u524d\u53ea\u63a2\u6d4b ~/.local/share/opencode/opencode.db\uff0cWindows \u7528\u6237\u7684 OpenCode v1.x \u6570\u636e\u6c38\u8fdc\u627e\u4e0d\u5230\uff0c\u5bfc\u5165\u9762\u677f\u663e\u793a\u300c\u672a\u68c0\u6d4b\u5230\u300d\u3002macOS/Linux \u884c\u4e3a\u4e0d\u53d8\u3002\u65b0\u589e\u9002\u914d\u5668\u4e13\u5c5e\u6d4b\u8bd5\uff08\u771f\u5b9e\u4e34\u65f6 SQLite \u5e93\u8986\u76d6\u4e09\u5e73\u53f0\u8def\u5f84\u4e0e\u4e09\u5c42\u8fd8\u539f\uff09\u3002", - "minPiDesktop": ">=0.14.3", - "shasum": "f2fb3c8f5a9f55e1f958852c2cb8394e2c80ee36268e1628e67dff36eabc4a99", - "url": "packages/io.github.muzimu217.session-import-0.4.9.piplug", - "sizeBytes": 3561159, - "permissions": [ - "ui.panel", - "ui.view", - "session.read", - "session.read.own", - "session.import", - "project.create", - "agent.complete", - "models.list", - "fs.read", - "fs.write" - ], - "fs": { - "read": { - "root": "workspace", - "scope": [ - "AGENTS.md", - "*.md", - "docs/**", - ".agents/skills/**" - ] - }, - "write": { - "root": "workspace", - "scope": [ - "AGENTS.md", - "*.md", - "docs/**", - ".agents/skills/**" - ] - } - } - } - ] - }, { "id": "io.github.muzimu217.usage-dashboard", "name": "Usage Dashboard", diff --git a/packages/io.github.muzimu217.session-import-0.4.1.piplug b/packages/io.github.muzimu217.session-import-0.4.1.piplug deleted file mode 100644 index ee762dc..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.1.piplug and /dev/null differ diff --git a/packages/io.github.muzimu217.session-import-0.4.2.piplug b/packages/io.github.muzimu217.session-import-0.4.2.piplug deleted file mode 100644 index 22707c3..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.2.piplug and /dev/null differ diff --git a/packages/io.github.muzimu217.session-import-0.4.4.piplug b/packages/io.github.muzimu217.session-import-0.4.4.piplug deleted file mode 100644 index 771e4fb..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.4.piplug and /dev/null differ diff --git a/packages/io.github.muzimu217.session-import-0.4.7.piplug b/packages/io.github.muzimu217.session-import-0.4.7.piplug deleted file mode 100644 index 2dacaab..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.7.piplug and /dev/null differ diff --git a/packages/io.github.muzimu217.session-import-0.4.8.piplug b/packages/io.github.muzimu217.session-import-0.4.8.piplug deleted file mode 100644 index 0ad7093..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.8.piplug and /dev/null differ diff --git a/packages/io.github.muzimu217.session-import-0.4.9.piplug b/packages/io.github.muzimu217.session-import-0.4.9.piplug deleted file mode 100644 index 2906b74..0000000 Binary files a/packages/io.github.muzimu217.session-import-0.4.9.piplug and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/.gitignore b/plugins/io.github.muzimu217.session-import/.gitignore deleted file mode 100644 index 849ddff..0000000 --- a/plugins/io.github.muzimu217.session-import/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist/ diff --git a/plugins/io.github.muzimu217.session-import/CHANGELOG.md b/plugins/io.github.muzimu217.session-import/CHANGELOG.md deleted file mode 100644 index ca46f38..0000000 --- a/plugins/io.github.muzimu217.session-import/CHANGELOG.md +++ /dev/null @@ -1,204 +0,0 @@ -# Changelog — Universal Session Import - -All notable changes to this plugin are documented here. Versions follow -semver; the plugin id is `io.github.muzimu217.session-import`. - -## 0.4.8 — 2026-09-11(导入行为修正) - -- **移除「归组到项目」勾选项**。该开关是空操作:宿主侧 `import_session` 只要 - `project_path` 非空就执行 `ensure_project(path)` 绑定项目,**完全不看 - `projectId`**;因此即便取消勾选、插件不传 projectPath,会话也照常被归到源项目, - 勾选毫无意义。现在改为**默认按项目归组**——有 `projectPath` 一律绑项目,无 - `projectPath` 的会话回落到独立的「会话」列表(经 `project_id IS NULL` 索引), - **永不丢数据**。 -- **删除导入完成后的「会话熔炉」原生通知**。该提示用户看不懂("点击右侧会话熔炉 - 开始蒸馏"),已移除;**会话熔炉功能本身保留**,仅去掉这条通知。`manifest` - 不再需要 `notify` 权限。 -- 导入完成提示简化为统一文案:已按项目归组,打开对应项目或在「项目」页查看。 -- 基于 0.4.7 之上发布;0.4.7 的声明式驱动层、自定义来源与安全加固全部保留不变。 - -## 0.4.7 — 2026-09-11(可扩展来源) - -- **新增声明式格式驱动层**:`jsonl-transcript` / `sqlite-session` / `json-tree` - 三个 driver 覆盖三类磁盘格式(一行一条 JSON 的 transcript、`session → message → part` - 的 SQLite、单文件即一会话的 JSON 树)。新增一个来源从"写一个 .js 适配器"变成"写一份配置"。 -- **用户可自定义来源**:工作区 `docs/session-import-sources.json`(`docs/**` 已在插件 - `fs.read` 范围内,无需新增权限),面板「自定义来源 → 重新加载」热加载,带 `自定义` 标签。 - - **安全边界**:配置只接受**纯数据**。任意层级出现 `eval` / `code` / `require` / - `transform` / `script` 等键即拒绝;数据根目录拒绝 `/`、Windows 盘符根与整个 home。 - `extension` 必须是真正的后缀(拒绝空字符串/`*`)。插件对用户主目录有 - - 字段说明与三个可直接复制的示例见 `docs/CUSTOM-SOURCES.md`。 -- **驱动层已用真实数据逐条比对校验**(`tools/validate-drivers-real.cjs`): - 同一份本机数据上,声明式 spec 与手写适配器并行跑,比对完整消息序列。 - **1693 个会话逐字节一致** —— Claude 61/61、Codex 774/774、WorkBuddy 159/159、 - ZCode 70/70、OpenCode 629/629。 - - 校验中修掉 7 个合成 fixture 测不出的缺陷:两阶段工具调用的三种形状(条目级 - `function_call`→`function_call_result`、块级 `tool_use`→`tool_result` 按 id 配对)、 - ZCode 的 `sequence` 排序列、Codex 条目 id 顶替会话 id、事件信封解包、注入文本剥离、 - 外部化输出回读、结果字段的 trim / 序列化口径。 - - 有意保留的差异:超出读取预算(默认单文件 32 MB / 20000 行)的文件,以及一条可导入 - 消息都没有的 transcript。 -- 包含 **0.4.6 健壮性加固**(看门狗超时、按 `externalId` 去重并标记 `oversized`、契约截断 - 显式回传 `truncated`)与 **0.4.5**(OpenCode v1.x SQLite 存储修复)。 - -## 0.4.4 — 2026-09-10 - -- **修复:`导入失败:toolResult exceeds 256 KiB`(整批被拒)**。 - 根因是**字节口径不一致**:宿主校验的是 - `TextEncoder().encode(JSON.stringify(field)).byteLength`(**序列化后**字节), - 而插件此前全部按**原始字符串**字节截断。JSON 转义会让引号/换行/反斜杠 - 密集的文本膨胀最多约 **44%**(实测 250000 原始字节 → 361113 序列化字节), - 因此一条按 256 KiB 原始截断的字段序列化后仍可达 ~365 KiB,单条超限 - 即导致**整批**被拒绝。 - - 真实数据命中:某 WorkBuddy 会话的 `toolResult`(一段 grep 输出) - 原始 406 KB,按旧逻辑截断后仍有 289 KB,依旧超限。 - - 修法:新增 `serializedBytes()` / `truncateToSerializedBytes()`, - `content`(512 KiB)与 `toolArgs` / `toolResult`(256 KiB)一律按 - **序列化后**字节做预算;另加 `enforceContractLimits()` 兜底,在发出前 - 按宿主的口径逐字段复测并硬夹紧——**一条脏数据绝不能让用户丢掉整批**。 - - 新增回归测试(引号/换行密集的超限样例):旧代码报 - `content ... got 747522`,新代码通过。真机复测:此前必失败的 - 652 条消息 WorkBuddy 会话现产出 5.3 MB 载荷、**0 项契约违规**; - 四来源 80 条会话全部通过,最差字段恰好落在 255 KiB / 406 KiB。 - -- **性能:扫描阶段大幅提速**(此前面板首屏等待 20s+,实测瓶颈不在导入 - 而在扫描): - - **Codex**:`~/.codex/sessions` 常有数百个多 MB 的 rollout - (实测 864 文件 / 2.6 GB / 58.3 万行),旧逻辑对每个文件 - `readFile` + 全行 `JSON.parse`。现改为**流式读取 + 提前中止**, - 只读到「会话 id + cwd + 首条真实用户消息」即停(55 MB 的文件只读 - 约 0.2% 字节);并新增 `scanFast()` 快速首屏(近 14 天窗口, - 0.43 s 出结果),全量扫描在后台补齐。 - - **WorkBuddy**:`summarizeFile()` 流式替代 `readFile` + `split` - (列表行需要文件尾部的 ai-title 与最后时间戳,无法提前中止, - 收益体现在内存与分配开销),并改为跨文件并发。 - - **Claude Code**:流式扫描(上一提交已落地)。 - - 后台全量扫描改为**排队**,只在所有前台扫描结束后启动——实测若并发, - Codex 的 2.6 GB 后台扫描会把同时进行的 WorkBuddy 扫描从 1.7 s - 拖慢到 9.5 s。 - - 效果:六来源并行首屏 **2.6 s** 全部就绪;Codex 首屏 **0.43 s**, - 后台约 10 s 补齐全量 776 条。 - -## 0.4.3 — 2026-09-10 - -- **修复:导入的会话在左侧项目列表不可见**。宿主侧栏语义(对照 v0.14.1 与 - 最新 main 逐字一致):侧栏项目组只由「用户打开过的项目 tab + - 当前工作区」创建;绑定了 projectId 的会话若其项目没有打开的 tab, - 则**在侧栏任何位置都不可见**(只能去「项目」索引页找)。上一版 - (0.4.2 引入的自动 project.create 绑定)因此把导入会话"藏"了起来。 - 现在: - - **默认归组到项目**(勾选项记忆用户选择):按 projectPath 调 - `project.create` 幂等解析 projectId 并随会话传入;配合宿主侧补丁 - (dev 仓 feat/plugin-import-project-tabs 分支,源自当年 - feat/zcode-session-import 的 c34c7927 移植到官方 importBatch 通道) - 导入完成后宿主自动把接收项目加入侧栏项目 tab 并展开——会话即时 - 出现在左侧「项目」列表; - - 取消勾选则导入到独立会话列表(无需宿主补丁,立即可见); - - 导入完成提示按两种落点分别说明在哪里查看。 -- **补报 `notify` 权限**:manifest 此前未声明 `notify`,导入完成的 - 原生通知实际一直在报 `PERMISSION_DENIED`(面板静默吞掉)。已补 - 声明,需要用户在宿主中重新授权一次。 -- 隔离数据目录实例(`PI_DESKTOP_DATA_DIR` + 9224 CDP)真机验证: - 归组导入 3 条 → `project_id` 绑定 `~/Work/club-web` + 侧栏「项目」 - 板块自动出现 club-web 组并展开(截图 docs/);独立导入 4 条 → - `project_id` 为空 + 侧栏「会话」列表即时显示。 - -## 0.4.2(未发布)— 2026-09-10 - -- **适配官方 `pi.session.importBatch` 正式版**(vastsa/PI-Desktop #169,提交 - a7e466fa / 8b9532cf / 15f1a440 已推 origin/main,宿主 schema v14): - - 修正工具载荷深度护栏:官方校验器对**整个 batch 输入**计 JSON 深度 - (≤8),`toolArgs` / `toolResult` 在输入中已嵌套 5 层,自身深度预算仅 3。 - 超深对象现在降级为字符串,一条脏数据不再污染整批(此前真实数据触发 - `session JSON depth exceeds 8` 整批拒绝)。 - - 逐条预检官方硬校验(title 非空 / externalId 非空),坏条目计为 - unreadable 跳过而非令整批失败;消息数按官方上限 2000 截断。 - - 导入统一走 `import.commit` 单一入口:官方 `importBatch` 优先,运行时 - 检测到宿主未接线(`host api not available` / `UNSUPPORTED` / - `unknown channel`)自动回退旧 `session.import` 桥接(dev 宿主分支 - feat/zcode-session-import),两者都不可用时给出明确中文指引。 - - `lib/forge.js` 适配官方 `session.list` 的 `{ items }` 返回形状 - (`sessionId` 投影到 `id`,兼容旧 `{ sessions }` 与裸数组)。 -- **真实 UI 端到端验证通过**(官方 origin/main 构建宿主 + CDP 驱动真实 - 交互):六来源扫描(ZCode 68 / WorkBuddy 151 / Claude Code 62 / Codex - 700+)→ 勾选 2 条 ZCode 会话 → `importBatch` 导入成功 → - `session_import_origins` 幂等键落库 → 侧栏即时刷新可见 → 点开会话完整 - 渲染;重复导入幂等跳过(已导入 0,跳过 2 重复)。证据截图 - `docs/e2e-v04-*.png`。 -- 修复 dev 注册表权限滞后问题:宿主启动恢复插件沿用 registry 记录的权限 - 而非 manifest,需同步更新(`session.import` 等新权限方能放行)。 - -## 0.4.1 — 2026-09-09 - -- 补齐市场元数据以符合官方仓 CONTRIBUTING 的推荐字段:新增 `i18n`(en / zh-CN 双语的 - name、description、safetyNotes)、`categories`、`changelog`、`safetyNotes`。 -- `engines.piDesktop` 由 `>=0.14.0` 上调为 `>=0.14.3`,与官方仓内同类社区插件一致 - (宿主实测版本 0.14.6-rc.3)。 -- 无功能变更。 - -## 0.4.0 — 2026-09-09 - -- 新增**会话熔炉**(`views/forge.html`,工作面板视图 `session-forge`): - 读回本插件导入过的会话,用宿主 `pi.agent.complete` 把它们蒸馏成项目约定、 - 已验证做法、反复出现的坑,一键写入工作区(默认 `AGENTS.md`)。 -- 新增 `lib/forge.js`:语料预算裁剪(均摊 + 单会话 200k 字符上限保护)、 - ADR 0174 限速保护(8 次 / 60 秒滚动窗口)、补全返回形状兼容。 -- 新增宿主能力自检 `forge.capabilities`,缺能力时给出明确提示而非静默失败。 -- **`contributes.sessionSources` 修正为对象数组**:此前写的字符串数组被 - `pi-plugin check` 拒绝。正确形状为 `{ id, label? }`,`id` 须匹配 - `^[a-zA-Z][a-zA-Z0-9._-]{0,63}$`(见 `packages/plugin-sdk/src/index.ts`)。 -- 权限收敛:移除未使用的 `fs.read`,仅保留蒸馏落盘需要的 `fs.write` - (范围 `AGENTS.md`、`*.md`、`docs/**`、`.agents/skills/**`)。 -- 架构备注:熔炉必须长在本插件内。`plugin_sessions::list` 的 SQL 为 - `WHERE oi.plugin_id = ?1`,任何插件只能读到自己导入的行,独立插件无法 - 读回宿主原生会话。 - -## 0.1.0 — 2026-09-09 - -- 首个版本:一体化来源检测与导入。 -- 「扫描本机工具」并行探测六个来源并显示各自会话数: - ZCode / WorkBuddy / Claude Code / Codex / OpenCode / Pi。 -- 选择来源后按项目分组列出会话,支持搜索、整组全选、会话预览 - (用户 / 助手 / 工具调用,含参数与结果,错误标红)。 -- 「导入为会话」经宿主 `session.import` 桥接把勾选会话写入 PI-Desktop - 会话库:按会话原始工作目录自动建项目、左侧即刻展开可见、 - 幂等 id(`import--`)重复导入自动跳过。 -- 来源实现来源:ZCode / WorkBuddy 适配器沿用本仓库两个单体插件的已验证逻辑; - Claude Code / Codex / OpenCode / Pi 移植自 PI-Desktop 内置导入器。 -- 官方 `pi-plugin check` 通过。 - -## 0.2.0 — 2026-09-09 - -- 来源会话列表新增**可折叠手风琴**:点击项目标题栏(箭头 + 项目名 + 会话数) - 展开或收起该项目下的会话;「全选该项目」按钮阻止事件冒泡,不会触发展开/收起。 -- 动效:箭头展开时顺时针平滑旋转 90°;列表区域以高度 + 透明度过渡展开收起 - (CSS grid-template-rows 过渡,Chromium 原生支持,无额外依赖)。 -- 默认全部展开;用户的展开/折叠偏好通过面板 localStorage 持久化, - 重开面板后保持。 - -## 0.3.0 — 2026-09-09(待官方 a7e466fa 推送后实测发布) - -- **适配官方会话 API([PI-Desktop#169](https://github.com/vastsa/PI-Desktop/issues/169) P0/P1)**: - 导入路径改为双通道—— - - 宿主提供 `pi.session.importBatch` 时走**官方契约**: - manifest 声明 `contributes.sessionSources`(六来源)与 `session.import` 权限; - 批量导入(契约上限 100 会话/批自动分批、mode: skip)、幂等键 - `(pluginId, source, externalId)`、host 生成 id。 - - 旧宿主回退到本地 dev 构建的 `session.import` 桥接(行为与 0.2.0 相同)。 -- **契约校验护栏**(转换输出 → `PluginSessionMessage`): - 严格 RFC3339 时间戳;`createdAt ≤ updatedAt`;消息时间单调不减(越界钳制); - title ≤ 200 字符、externalId ≤ 256 字符;单条 content ≤ 512 KiB; - toolArgs/toolResult 序列化 ≤ 256 KiB、JSON 深度 ≤ 8(超深自动降级为字符串)。 -- 导入结果反馈细化:imported / skipped / failed 分开统计并逐条展示失败原因。 -- 注:官方契约中导入会话默认不绑定项目/provider/model(`project_id` NULL, - 历史值存 origin 侧车);侧栏呈现方案见我们在 #169 的反馈建议。 - -## 0.3.0-real-UI 验证记录(2026-09-09) - -在宿主 `feat/zcode-session-import` 分支(含 c34c7927 桥接补丁)上通过 CDP 真实鼠标交互完成端到端验证(截图见 docs/e2e-*.png): - -- 面板扫描:ZCode 66 / WorkBuddy 126 / Claude Code 62 / Codex 767 -- 幂等跳过:重复导入已导入会话 → “已导入 0 个(跳过 1 个重复/不可读)” -- 新导入:ZCode《MCP跨平台打包工具项目评估》→ “已导入 1 个会话(跳过 0)” -- **侧栏即时可见**:`sessionsChanged` 桥接生效,侧栏立即出现 ForgeKit 项目分组与新会话(对照:在无桥接的分支上导入成功但侧栏不刷新) -- 点开会话:消息完整渲染(markdown / 代码段正常) diff --git a/plugins/io.github.muzimu217.session-import/LICENSE b/plugins/io.github.muzimu217.session-import/LICENSE deleted file mode 100644 index 365e405..0000000 --- a/plugins/io.github.muzimu217.session-import/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 muzimu217 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/plugins/io.github.muzimu217.session-import/README.md b/plugins/io.github.muzimu217.session-import/README.md deleted file mode 100644 index 5995e6c..0000000 --- a/plugins/io.github.muzimu217.session-import/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# Universal Session Import(一体化会话导入) - -PI-Desktop 插件:**扫描本机安装的编程工具,把它们(ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi)的本地会话统一导入 PI-Desktop。** - -![scan sources](docs/screenshots/01-scan-sources.png) - -## 使用流程 - -### 1. 扫描本机工具 - -打开面板点击「扫描本机工具」,插件并行探测六个来源,实时显示各自检测到的会话数(未安装的工具显示「未检测到」)。 - -### 2. 选择来源,浏览会话 - -点击检测到的工具卡片,按项目分组载入该来源的会话列表(支持搜索、整组全选): - -![session list](docs/screenshots/02-session-list.png) - -### 3. 预览对话 - -点任意会话即可按原始顺序还原完整对话——用户消息、助手回复、工具调用(参数 + 结果折叠展示,错误标红): - -![preview](docs/screenshots/03-preview.png) - -### 4. 导入为会话 - -勾选会话(支持整组全选)→「导入为会话」,通过宿主 `session.import` 通道写入 PI-Desktop 会话库: - -![import status](docs/screenshots/04-import-status.png) - -导入的会话按**原始工作目录**归属项目:该项目已在 PI-Desktop 打开时**立刻展开可见**;未打开的项目也会自动加入左侧项目列表并展开: - -![sidebar auto expand](docs/screenshots/05-sidebar-auto-expand.png) - -![sidebar codex](docs/screenshots/06-sidebar-codex.png) - -重复导入自动跳过(幂等 id:`import-<来源>-<外部会话 id>`)。 - -## 来源与特殊处理 - -| 来源 | 数据位置 | 特殊处理 | -| --- | --- | --- | -| ZCode | `~/.zcode/cli/db/db.sqlite` | `node:sqlite` 只读;过滤 `subagent_child` 内部会话与 `synthetic` 注入文本;工具调用按存储顺序还原 | -| WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` | 剥离 ``/`` 注入块;`function_call` 按 `callId` 配对;外置大结果安全回读;`ai-title` 优先 | -| Claude Code | `~/.claude/projects/**/*.jsonl` | 过滤 sidechain 与合成用户行;`tool_use`/`tool_result` 配对 | -| Codex | `~/.codex/sessions/**/*.jsonl` | 兼容新旧两种格式;过滤 `# AGENTS.md` 等合成行 | -| OpenCode | `%LOCALAPPDATA%\opencode\data\opencode.db`(Windows)/ `~/.local/share/opencode/opencode.db`(macOS/Linux,尊重 `XDG_DATA_HOME`) | `node:sqlite` 只读;session→message→part 三层还原(OpenCode v1.x SQLite);多候选路径按序探测 | -| Pi | `~/.pi/agent/sessions/**/*.jsonl` | `toolCall`/`toolResult` 配对;`session_info.name` 优先 | - -## 自定义来源(无需改代码) - -内置 6 个来源之外,你可以在**当前工作区**放一份纯 JSON,描述自己的工具: - -``` -docs/session-import-sources.json -``` - -内置驱动覆盖三类磁盘格式,绝大多数工具都能落在其中一个上: - -| driver | 适用 | 内置使用者 | -| --- | --- | --- | -| `jsonl-transcript` | 一行一条记录的 JSONL 对话文件 | Claude Code、Codex、WorkBuddy、Pi | -| `sqlite-session` | SQLite:`session → message → part` 三层(或 session+message 两层) | ZCode、OpenCode | -| `json-tree` | 一个 JSON 文件即一个会话 | 旧版 OpenCode / Claude 布局 | - -面板「自定义来源 → 重新加载」即可热加载,新来源带 `自定义` 标签出现。完整字段见 -[`docs/CUSTOM-SOURCES.md`](docs/CUSTOM-SOURCES.md),里面有可直接复制的三个 driver 示例。 - -> **安全边界**:配置**只允许纯数据**。任何 `eval` / `code` / `require` / `transform` / `script` -> 之类的键(任意层级)都会被拒绝——插件对用户主目录有读权限,允许配置携带代码等同于任意代码执行。 -> 数据根目录也不接受 `/` 或整个 home。 - -## 权限 - -| 权限 | 用途 | -| --- | --- | -| `ui.panel` / `ui.view` | 导入面板与「会话熔炉」工作面板视图 | -| `session.read` / `session.read.own` | 读回本插件导入过的会话,供熔炉蒸馏 | -| `session.import` | 把选中的会话写入 PI-Desktop 会话库 | -| `project.create` | 按会话的 `projectPath` 幂等解析 / 绑定 projectId(无此能力的宿主退回未绑定导入) | -| `agent.complete` / `models.list` | 调用宿主模型做蒸馏(使用宿主凭据与额度,插件不接触 API Key) | -| `fs.read` | 只读各工具的本地会话数据(`.jsonl` / SQLite 的 `readOnly` 模式);并读取工作区 `docs/session-import-sources.json` 里的自定义来源配置 | -| `fs.write` | **仅**用于保存蒸馏结果,范围限定在工作区内的 `AGENTS.md`、`*.md`、`docs/**`、`.agents/skills/**` | - -读取各工具本地数据由插件进程**只读**完成(`node:sqlite` 的 `readOnly` 模式,或直接读取 -`.jsonl`)。**导入**经由宿主 `session.import` 通道完成(该通道为插件宿主新增的底层 API -提案,见 [PI-Desktop#134](https://github.com/vastsa/PI-Desktop/issues/134))。 - -插件不发起任何网络请求、无遥测上报;熔炉调用走宿主 `pi.agent.complete`,凭据保留在 -Electron 主进程。 - -## 开发 - -```bash -# 在 PI-Desktop 仓库内校验与打包 -pnpm --filter @pi-desktop/plugin-devkit... build -node packages/plugin-devkit/dist/cli.js check /absolute/path/to/pi-desktop-session-import -node packages/plugin-devkit/dist/cli.js pack /absolute/path/to/pi-desktop-session-import -``` - -在 PI-Desktop 中:插件页 → Load development plugin → 选择本目录。 - -## License - -MIT diff --git a/plugins/io.github.muzimu217.session-import/docs/CUSTOM-SOURCES.md b/plugins/io.github.muzimu217.session-import/docs/CUSTOM-SOURCES.md deleted file mode 100644 index c771f9b..0000000 --- a/plugins/io.github.muzimu217.session-import/docs/CUSTOM-SOURCES.md +++ /dev/null @@ -1,217 +0,0 @@ -# 自定义扫描来源(Custom Sources) - -除了内置的 6 个来源(ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi), -你可以**用一份声明式 JSON 描述自己的工具**,让它出现在扫描列表里并像内置来源一样导入。 - -> **安全边界**:配置**只允许纯数据**。任何 `eval` / `code` / `require` / `transform` / `script` -> 之类的键(任意层级)都会被直接拒绝——插件运行时对用户主目录有读权限, -> 允许配置文件携带代码等同于任意代码执行。因此这里**不支持、也不会执行**任何自定义 JS。 - -## 放哪儿 - -在**当前工作区**创建: - -``` -docs/session-import-sources.json -``` - -`docs/**` 已经在插件声明的 `fs.read` 范围内,所以这个位置**不需要新增任何权限**。 -文件格式可以是数组,也可以是 `{ "sources": [...] }`: - -```json -[ - { "id": "mytool", "label": "My Tool", "driver": "jsonl-transcript", "root": "~/.mytool/sessions", "entry": { ... } } -] -``` - -写完在面板点「自定义来源 → 重新加载」,新来源会立刻出现在扫描卡片上(带 `自定义` 标签)。 - -## 三个 driver(对应三种磁盘格式) - -| driver | 适用 | 内置使用者 | -| --- | --- | --- | -| `jsonl-transcript` | 一行一条记录的 JSONL 对话文件 | Claude Code、Codex、WorkBuddy、Pi | -| `sqlite-session` | SQLite:`session → message → part` 三层(或 session+message 两层) | ZCode、OpenCode | -| `json-tree` | 一个 JSON 文件即一个会话,对话嵌在其中 | 旧版 OpenCode / Claude 布局 | - -### 1. jsonl-transcript - -```json -{ - "id": "mytool", - "label": "My Tool", - "driver": "jsonl-transcript", - "root": "~/.mytool/sessions", - "extension": ".jsonl", - "recursive": true, - "session": { - "idFrom": "filename", - "titleFrom": "firstUser", - "projectFrom": "parentDir" - }, - "entry": { - "rolePath": "role", - "roleMap": { "human": "user" }, - "content": { "blocks": { "path": "message.content", "typeField": "type", "types": ["text"], "textField": "text" } }, - "tsPath": "timestamp", - "skipTypePath": "type", - "skipTypes": ["summary", "system"], - "tool": { - "typePath": "type", - "toolTypes": ["tool_use"], - "namePath": "name", - "argsPath": "input", - "resultPath": "output", - "statusPath": "status" - } - } -} -``` - -`content` 支持四种写法(决定了文本怎么取): - -| 写法 | 含义 | -| --- | --- | -| `"content"` 或 `{ "path": "content" }` | 取该字段 | -| `{ "blocks": { "path": "message.content", "typeField": "type", "types": ["text"], "textField": "text" } }` | 数组里挑出指定 type 的块,拼接其 text(Claude 的 content 就是这种块数组) | -| `{ "first": [ {...}, {...} ] }` | 依次尝试,取第一个非空 | -| `{ "literal": "固定文本" }` | 常量 | - -#### 其它 entry 键 - -| 键 | 作用 | -| --- | --- | -| `match: { "path": "type", "in": ["user","assistant"] }` | 只处理这类条目,其余整条跳过(sidecar 记录、标题事件等) | -| `skipTypePath` + `skipTypes` | 命中即跳过(Claude 的 `isSidechain: true` 分支) | -| `unwrapPath` | 条目被信封包着时(Codex 的 `{timestamp,type,payload}`)把内层对象提上来,所有 path 相对它;内层不存在时自动退回原条目,新旧两种格式一份 spec 通吃 | -| `drop: { "startsWith": ["<"], "roles": ["user"] }` | 丢弃注入文本(各家的 system-reminder / AGENTS.md 都是这个形状)。`roles` 限定只作用于某个角色 | -| `textOps` | 见下 | - -#### textOps(纯字符串后处理) - -有些工具把注入内容包在 XML 标签里,需要剥掉才能拿到真实提问。`textOps` 只做**字符串运算**,不是代码: - -```json -"textOps": [ - { "op": "stripXmlBlocks", "tags": ["system-reminder", "cb_summary"], "roles": ["user"] }, - { "op": "extractXmlTag", "tag": "user_query", "roles": ["user"] } -] -``` - -| op | 作用 | -| --- | --- | -| `stripXmlBlocks` | 删掉 `…`(成对)和末尾未闭合的 `` | -| `extractXmlTag` | 若存在 `…`,返回其内部文本,否则原样返回 | - -标签名会做字符集校验(只接受 `[A-Za-z][A-Za-z0-9._-]`)并限制数量,不会变成任意正则。 - -#### 工具调用(toolCall) - -工具调用有三种落地形状,用 `entry.toolCall` 或 `entry.tool` 描述: - -**A. 条目级两阶段**(WorkBuddy `function_call` → `function_call_result`;Codex `function_call` → `function_call_output`): - -```json -"toolCall": { - "call": { "typePath": "type", "types": ["function_call"], "idPath": "callId", "namePath": "name", "argsPath": "arguments", "argsJson": true }, - "result": { "typePath": "type", "types": ["function_call_result"], "idPath": "callId", "namePath": "name", "resultPath": "output", "statusPath": "status" } -} -``` - -**B. 块级两阶段**(Claude:assistant 消息里的 `tool_use` 块,由后面 user 消息里的 `tool_result` 块按 id 对上): - -```json -"toolCall": { - "callBlocks": { "path": "message.content", "typeField": "type", "type": "tool_use", "idPath": "id", "namePath": "name", "argsPath": "input", "roles": ["assistant"] }, - "resultBlocks": { "path": "message.content", "typeField": "type", "type": "tool_result", "idPath": "tool_use_id", "resultPath": "content", "statusPath": "is_error", "roles": ["user"] } -} -``` - -两种情况都**在结果出现时才生成 tool 消息**,调用阶段只记下 name/args —— 与内置适配器逐字一致,导入后的消息顺序不会错位。 -带 `resultBlocks` 的条目如果同时有文本,文本会被抑制(Claude 内置适配器同样如此)。 - -**C. 单条目自带结果**(一个条目既是调用也是结果):用 `entry.tool`(见上面完整示例)。 - -补充键: - -| 键 | 作用 | -| --- | --- | -| `toolCall.emitUnpaired` | 默认 `false`:没有结果的调用会被丢弃(与内置一致)。设 `true` 会保留成 `running` 状态的 tool 消息 | -| `*.follow: { "marker": "Full output saved to:", "maxBytes": 4194304 }` | 结果被截断成"完整输出已存到 xxx"时,把落盘内容读回来。**只会在 spec 自己的数据根目录内读取**,越界一律保持原文本 | -| `*.argsJson: true` | 参数是 JSON 字符串时解析成对象 | -| `*.errorValues: ["error"]` | 显式指定哪些状态值算失败 | -| `*.resultFormat: "json"` | 结果**不解析块**:字符串原样,其余 `JSON.stringify`(Codex 的形状)。默认是 `"text"`:从块里取可读文本 | - -#### session 键 - -| 键 | 作用 | -| --- | --- | -| `idFrom` | 会话 id 的取值规则(支持 `first`);`"filename"` 用文件名 | -| `idFromEntry` | 先在**哪类条目**里找 id。Codex 每个 `response_item` 自己也有 `id`,不加这层就会把条目 id 当成会话 id:`{ "path": "type", "in": ["session_meta"] }` | -| `titleFrom` / `projectFrom` / `fallbackProject` | 标题与项目名来源 | - -### 2. sqlite-session - -```json -{ - "id": "mytool", - "label": "My Tool", - "driver": "sqlite-session", - "db": "~/.mytool/data.db", - "session": { "table": "session", "idCol": "id", "titleCol": "title", "pathCol": "directory", "createdCol": "time_created", "updatedCol": "time_updated" }, - "message": { "table": "message", "idCol": "id", "sessionIdCol": "session_id", "createdCol": "time_created", "dataCol": "data", "rolePath": "role", "tsPath": "time.created", "modelIdPath": "modelID", "providerIdPath": "providerID" }, - "part": { "table": "part", "messageIdCol": "message_id", "sessionIdCol": "session_id", "createdCol": "time_created", "dataCol": "data", "textTypes": ["text"], "toolType": "tool", "toolNamePath": "tool", "argsPath": "state.input", "resultPath": "state.output", "statusPath": "state.status" } -} -``` - -如果工具把正文直接存在 message 行里(没有 part 表),**省略 `part`** 并在 `message` 上加 `contentPath`。 -数据库一律以 `readOnly` 打开,不会写入你的数据。 - -排序:驱动会检查表里有没有 `sequence` 列(ZCode 有、OpenCode 没有),有就按 -`sequence, 时间, id` 排,否则按 `时间, id`。同一毫秒内写入的 part 因此不会错位。 -可以用 `message.sequenceCol` / `part.sequenceCol` 改列名,设 `null` 则强制不用。 - -`session.exclude` 可以结构化地过滤行(值是绑定参数,不是拼 SQL;列不存在时忽略): - -```json -"session": { "exclude": [{ "col": "task_type", "equals": "subagent_child" }] } -``` - -### 3. json-tree - -```json -{ - "id": "mytool", - "label": "My Tool", - "driver": "json-tree", - "root": "~/.mytool/sessions", - "extension": ".json", - "session": { "idPath": "id", "titlePath": "title", "tsPath": "createdAt", "pathPath": "directory", "messagesPath": "messages" }, - "message": { "rolePath": "role", "content": { "path": "content" }, "tsPath": "createdAt" } -} -``` - -## 校验规则(会被拒绝的情况) - -- `id` 不合法:必须匹配 `^[a-zA-Z][a-zA-Z0-9._-]{0,63}$` -- `id` 撞内置来源:`zcode` / `workbuddy` / `claude-code` / `codex` / `opencode` / `pi`(内置永远优先) -- `driver` 不是上表三个之一 -- 出现 `eval` / `code` / `require` / `transform` / `script` / `__proto__` / `prototype` / `constructor` 等键(**任意层级**) -- 出现非数据值(函数等) -- 数据根目录是 `/` 或整个 home(拒绝扫描整个磁盘/家目录;请指到具体子目录) -- 配置文件 > 512 KB,或来源数 > 25 - -被拒绝的来源不会中断扫描:其它来源照常工作,面板的「自定义来源」区域会显示具体原因。 - -## 资源上限 - -默认每个来源最多扫 **2000 个文件**、单文件 **32 MB**、单文件最多解析 **20000 行** -(可在 spec 里用 `maxFiles` / `maxBytes` / `maxLines` 覆盖)。 -加上既有的看门狗超时(单源扫描 90s),一个配置错误的来源不会拖死面板。 - -两者的行为不同,请注意: - -- 超过 `maxBytes` 的文件**整个跳过**(不会读进内存)。 -- 超过 `maxLines` 的文件**只解析前 N 行**。这是有意的设计取舍:内置适配器会把 - 50 MB 的会话整个读进内存,插件不能这么做。需要覆盖时请自己承担内存代价 - (实测把 Codex 上限提到 128 MB 时,校验进程峰值约 1 GB)。 diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-import-result.png b/plugins/io.github.muzimu217.session-import/docs/e2e-import-result.png deleted file mode 100644 index 32add63..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-import-result.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-opened-session.png b/plugins/io.github.muzimu217.session-import/docs/e2e-opened-session.png deleted file mode 100644 index 273f7c3..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-opened-session.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-projects-list-visible.png b/plugins/io.github.muzimu217.session-import/docs/e2e-projects-list-visible.png deleted file mode 100644 index a740dbd..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-projects-list-visible.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-sidebar-shows-import.png b/plugins/io.github.muzimu217.session-import/docs/e2e-sidebar-shows-import.png deleted file mode 100644 index ccff821..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-sidebar-shows-import.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-1-scanned.png b/plugins/io.github.muzimu217.session-import/docs/e2e-v04-1-scanned.png deleted file mode 100644 index 259c67d..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-1-scanned.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-2-selected.png b/plugins/io.github.muzimu217.session-import/docs/e2e-v04-2-selected.png deleted file mode 100644 index 4c62949..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-2-selected.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-3-import-batch-ok.png b/plugins/io.github.muzimu217.session-import/docs/e2e-v04-3-import-batch-ok.png deleted file mode 100644 index d302dfb..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-3-import-batch-ok.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-4-sidebar-refresh.png b/plugins/io.github.muzimu217.session-import/docs/e2e-v04-4-sidebar-refresh.png deleted file mode 100644 index 963e5fc..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-4-sidebar-refresh.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-5-opened-session.png b/plugins/io.github.muzimu217.session-import/docs/e2e-v04-5-opened-session.png deleted file mode 100644 index cc52552..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/e2e-v04-5-opened-session.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/01-scan-sources.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/01-scan-sources.png deleted file mode 100644 index 33d5db0..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/01-scan-sources.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/02-session-list.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/02-session-list.png deleted file mode 100644 index 563c17e..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/02-session-list.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/03-preview.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/03-preview.png deleted file mode 100644 index 808e5e1..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/03-preview.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/04-import-status.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/04-import-status.png deleted file mode 100644 index 558fd4e..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/04-import-status.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/05-sidebar-auto-expand.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/05-sidebar-auto-expand.png deleted file mode 100644 index ef67be1..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/05-sidebar-auto-expand.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/docs/screenshots/06-sidebar-codex.png b/plugins/io.github.muzimu217.session-import/docs/screenshots/06-sidebar-codex.png deleted file mode 100644 index 9a8cf76..0000000 Binary files a/plugins/io.github.muzimu217.session-import/docs/screenshots/06-sidebar-codex.png and /dev/null differ diff --git a/plugins/io.github.muzimu217.session-import/lib/bus.js b/plugins/io.github.muzimu217.session-import/lib/bus.js deleted file mode 100644 index 37be078..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/bus.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Tiny typed event bus for the session-import plugin. - * - * Why a bus at all? Cross-view state inside PI-Desktop plugins cannot use a - * native push channel — `pluginBridge.invoke` is pull-only. The bus lives - * inside main.js's Node process: importers emit `session.imported` after a - * successful batch, and the forge view pulls a snapshot through the - * `import.recent` channel on each refresh. The bus keeps that snapshot - * race-free and lets future subscribers (notifications, Agent tools) tap in. - * - * Storage: small in-memory state, no persistence. The plugin process exits - * when PI-Desktop unloads the plugin anyway. - */ -"use strict"; - -const { EventEmitter } = require("node:events"); - -/** Canonical event names — single source of truth across main.js + UI. */ -const EVENTS = Object.freeze({ - /** Emitted after a successful official or legacy import batch. */ - SESSION_IMPORTED: "session.imported", - /** Emitted after a successful forge.save (distilled rule written to disk). */ - DISTILLATION_SAVED: "distillation.saved", - /** Emitted when a scan error is recorded (F-10 observability). */ - SCAN_FAILED: "scan.failed", -}); - -const emitter = new EventEmitter(); -// Plugins can fire any number of events; don't crash the host on a single -// misbehaving listener. -emitter.setMaxListeners(50); - -/** Snapshot of the most recent import — refreshable by forge view. */ -const recentImport = { - at: null, // Date - count: 0, - source: null, -}; - -function recordImport({ count, source }) { - recentImport.at = new Date(); - recentImport.count = count; - recentImport.source = source ?? null; - emitter.emit(EVENTS.SESSION_IMPORTED, { ...recentImport }); -} - -function recordDistillation({ path, bytes }) { - emitter.emit(EVENTS.DISTILLATION_SAVED, { at: new Date(), path, bytes }); -} - -function recordScanFailure({ source, code, message }) { - emitter.emit(EVENTS.SCAN_FAILED, { source, code, message, at: new Date() }); -} - -function on(evt, listener) { emitter.on(evt, listener); } -function once(evt, listener) { emitter.once(evt, listener); } -function off(evt, listener) { emitter.off(evt, listener); } - -module.exports = { - EVENTS, - recentImport, - recordImport, - recordDistillation, - recordScanFailure, - on, once, off, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/custom-sources.js b/plugins/io.github.muzimu217.session-import/lib/custom-sources.js deleted file mode 100644 index a8593ff..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/custom-sources.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * User-defined sources (declarative only). - * - * A user can add a source by dropping a JSON spec at - * /docs/session-import-sources.json - * `docs/**` is already inside the plugin's declared fs.read scope, so this - * needs no new permission. - * - * SECURITY: the plugin runs with full read access to the user's home - * directory. Accepting executable code from a config file would therefore be - * arbitrary code execution. Specs are validated to be pure data: - * - no functions / non-JSON values - * - no keys from a code-ish denylist (eval/require/script/transform/...) - * - driver must be a registered driver name - * - id must match the plugin-sdk source id regex and must not shadow a - * built-in source - * - the data root cannot be absurdly broad ("/" or the home dir itself) - */ -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const os = require("node:os"); -const { driverNames } = require("./drivers"); -const { isFilesystemRoot, EXTENSION_RE } = require("./drivers/fsutil"); - -/** Relative to the workspace root; inside the declared fs.read scope. */ -const CONFIG_PATH = "docs/session-import-sources.json"; - -/** plugin-sdk/src/index.ts — source id regex. */ -const SOURCE_ID_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; - -/** Built-in source ids that must never be shadowed by a custom spec. */ -const BUILTIN_IDS = ["zcode", "workbuddy", "claude-code", "codex", "opencode", "pi"]; - -/** - * Keys that would turn data into behaviour. Any occurrence anywhere in the - * spec (at any depth) is rejected outright. - */ -const FORBIDDEN_KEYS = new Set([ - "eval", - "function", - "functions", - "require", - "import", - "script", - "code", - "transform", - "exec", - "spawn", - "constructor", - "__proto__", - "prototype", -]); - -const MAX_SPECS = 25; -const MAX_JSON_BYTES = 512 * 1024; -const MAX_DEPTH = 12; - -function isPlainObject(v) { - return v !== null && typeof v === "object" && !Array.isArray(v); -} - -/** Reject any spec containing code-ish keys or non-data values, at any depth. */ -function assertDeclarative(value, errors, at = "$", depth = 0) { - if (depth > MAX_DEPTH) { - errors.push(`${at}: nesting too deep (max ${MAX_DEPTH})`); - return; - } - if (Array.isArray(value)) { - value.forEach((v, i) => assertDeclarative(v, errors, `${at}[${i}]`, depth + 1)); - return; - } - if (!isPlainObject(value)) { - const t = typeof value; - if (!["string", "number", "boolean"].includes(t) && value !== null) { - errors.push(`${at}: unsupported value type "${t}" (specs must be plain data)`); - } - return; - } - for (const key of Object.keys(value)) { - if (FORBIDDEN_KEYS.has(key.toLowerCase())) { - errors.push(`${at}.${key}: forbidden key (specs are declarative data only)`); - continue; - } - assertDeclarative(value[key], errors, `${at}.${key}`, depth + 1); - } -} - -function resolveAndGuard(p, errors, field) { - if (typeof p !== "string" || !p.trim()) { - errors.push(`${field}: must be a non-empty string path`); - return null; - } - const trimmed = p.trim(); - if (trimmed === "/" || trimmed === "\\" || /^[A-Za-z]:[\\/]?$/.test(trimmed)) { - errors.push(`${field}: refusing to scan the filesystem root`); - return null; - } - const home = os.homedir(); - const expanded = - trimmed === "~" ? home : trimmed.startsWith("~/") ? path.join(home, trimmed.slice(2)) : trimmed; - const abs = path.normalize(path.isAbsolute(expanded) ? expanded : path.resolve(expanded)); - if (isFilesystemRoot(abs)) { - errors.push(`${field}: refusing to scan the filesystem root`); - return null; - } - if (abs === home) { - errors.push(`${field}: refusing to scan the entire home directory (point at a subdir)`); - return null; - } - return abs; -} - -/** Validate one spec; returns { ok, errors, spec } (spec has resolved paths). */ -function validateSpec(raw, index) { - const errors = []; - const at = `specs[${index}]`; - - if (!isPlainObject(raw)) { - return { ok: false, errors: [`${at}: must be an object`], spec: null }; - } - - assertDeclarative(raw, errors, at); - - const id = raw.id; - if (typeof id !== "string" || !SOURCE_ID_RE.test(id)) { - errors.push(`${at}.id: must match ${SOURCE_ID_RE}`); - } else if (BUILTIN_IDS.includes(id)) { - errors.push(`${at}.id: "${id}" is a built-in source and cannot be overridden`); - } - - const label = raw.label; - if (typeof label !== "string" || !label.trim()) { - errors.push(`${at}.label: must be a non-empty string`); - } - - const driver = raw.driver; - if (typeof driver !== "string" || !driverNames().includes(driver)) { - errors.push(`${at}.driver: must be one of ${driverNames().join(", ")}`); - } - - // Each driver carries its data under a different key; resolve + guard it. - const spec = { ...raw }; - if (driver === "sqlite-session") { - const db = resolveAndGuard(raw.db, errors, `${at}.db`); - if (db) spec.db = db; - } else { - const root = resolveAndGuard(raw.root, errors, `${at}.root`); - if (root) spec.root = root; - if (raw.extension != null && (typeof raw.extension !== "string" || !EXTENSION_RE.test(raw.extension))) { - errors.push(`${at}.extension: must be a dotted suffix like .jsonl`); - } - } - - if (errors.length) return { ok: false, errors, spec: null }; - return { ok: true, errors: [], spec }; -} - -/** - * Load and validate every custom spec for a workspace. - * Missing / malformed config is not an error — it just yields no sources. - */ -async function loadCustomSources(options = {}) { - const { workspaceRoot = null, readText = null } = options; - const result = { specs: [], errors: [], configPath: CONFIG_PATH }; - - let raw = null; - if (typeof readText === "function") { - // Preferred path: host-mediated read. The host resolves CONFIG_PATH - // against the workspace and enforces the declared fs.read scope, so the - // plugin never reaches outside its permissions. - try { - raw = await readText(CONFIG_PATH); - } catch { - return result; // absent / unreadable -> simply no custom sources - } - } else if (workspaceRoot) { - // Fallback for tests and for hosts without a usable fs bridge. - const configPath = path.join(workspaceRoot, CONFIG_PATH); - result.configPath = configPath; - try { - const st = fs.statSync(configPath); - if (st.size > MAX_JSON_BYTES) { - result.errors.push(`config too large (${st.size} bytes > ${MAX_JSON_BYTES})`); - return result; - } - raw = fs.readFileSync(configPath, "utf8"); - } catch { - return result; - } - } else { - return result; - } - - if (typeof raw !== "string" || !raw.trim()) return result; - if (Buffer.byteLength(raw) > MAX_JSON_BYTES) { - result.errors.push(`config too large (> ${MAX_JSON_BYTES} bytes)`); - return result; - } - - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - result.errors.push(`config is not valid JSON: ${err && err.message}`); - return result; - } - - const list = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.sources) ? parsed.sources : null; - if (!list) { - result.errors.push('config must be an array of specs, or { "sources": [...] }'); - return result; - } - if (list.length > MAX_SPECS) { - result.errors.push(`too many specs (${list.length} > ${MAX_SPECS})`); - return result; - } - - list.forEach((rawSpec, i) => { - const { ok, errors, spec } = validateSpec(rawSpec, i); - if (ok) result.specs.push(spec); - else result.errors.push(...errors); - }); - - return result; -} - -module.exports = { - CONFIG_PATH, - SOURCE_ID_RE, - BUILTIN_IDS, - FORBIDDEN_KEYS, - validateSpec, - loadCustomSources, - assertDeclarative, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/entry-map.js b/plugins/io.github.muzimu217.session-import/lib/drivers/entry-map.js deleted file mode 100644 index 87fa513..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/entry-map.js +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Shared entry -> message mapper. - * - * Both the jsonl-transcript and json-tree drivers read a stream of "entries" - * (one line / one array item) and turn each into messages using declarative - * rules, so the mapping lives here rather than being duplicated. - * - * Three tool shapes are supported, because that is what real agents write: - * - * 1. self-contained tool entry (`entry.tool`) - * the whole entry IS a call+result (e.g. an OpenCode tool part) - * - * 2. two-phase tool events at entry level (`entry.toolCall.call` / `.result`) - * `function_call` -> `function_call_output` (Codex) - * `function_call` -> `function_call_result` (WorkBuddy) - * - * 3. two-phase tool events as content blocks (`entry.toolCall.callBlocks` / - * `.resultBlocks`) - * a `tool_use` block inside an assistant message, whose `tool_result` - * block arrives in a later user message (Claude Code). - * - * Shapes 2 and 3 need state across entries to pair an id with its result, so - * `mapEntry` takes a `pending` map. Results are emitted where the built-in - * adapters emit them — at result time, not call time — so a declarative spec - * produces the same message sequence as the hand-written adapter. - */ -"use strict"; - -const path = require("node:path"); -const fsp = require("node:fs/promises"); - -const { - extractText, - extractValue, - getPath, - asText, - mapRole, - toIso, - applyTextOps, - droppedBy, -} = require("./extract"); - -/** Only these are conversation roles; everything else is metadata. */ -const TEXT_ROLES = new Set(["user", "assistant"]); - -const DEFAULT_FOLLOW_MAX_BYTES = 1024 * 1024; - -const isInside = (file, root) => file === root || file.startsWith(root + path.sep); - -/** Status for a self-contained / entry-level tool event (no result seen yet). */ -function mapToolStatus(status) { - const s = status == null ? "" : String(status); - if (s === "error" || s === "failed" || s === "true") return "error"; - if (s === "completed" || s === "success" || s === "false") return "success"; - return "running"; -} - -/** Status for a paired tool result: a result exists, so it is error or success. */ -function resultStatus(value, errorValues) { - const s = value == null ? "" : String(value); - if (Array.isArray(errorValues) && errorValues.length) { - return errorValues.map(String).includes(s) ? "error" : "success"; - } - if (s === "error" || s === "failed" || s === "true") return "error"; - return "success"; -} - -/** Text out of a block-array field (or a plain string), ignoring other types. */ -function blockValueText(value, typeField, types, textField) { - if (value == null) return ""; - if (typeof value === "string") return value; - if (!Array.isArray(value)) return ""; - const out = []; - for (const b of value) { - if (b == null) continue; - if (typeof b !== "object") { - out.push(String(b)); - continue; - } - const t = typeField ? getPath(b, typeField) : undefined; - if (Array.isArray(types) && types.length && !types.includes(t)) continue; - const txt = textField ? getPath(b, textField) : undefined; - if (typeof txt === "string" && txt) out.push(txt); - } - return out.join("\n").trim(); -} - -const DEFAULT_RESULT_TEXT_TYPES = ["text", "output_text", "input_text"]; - -/** - * A tool result may be a string, a block array, or an object with .text. - * - * Trimming follows the built-in adapters exactly: a plain string is returned - * verbatim (claude.js / codex.js do not trim), while text joined out of blocks - * or read off an object is trimmed (blockText / workbuddy's resultText do). - */ -function resultToText(value, rule = {}) { - if (value == null) return ""; - if (typeof value === "string") return value; - // "json": the app stores the result verbatim and its adapter stringifies - // whatever it cannot read as a plain string (codex.js). Preferring the raw - // payload over a block scan is what keeps those two byte-identical. - if (rule.resultFormat === "json") return asText(value); - if (Array.isArray(value)) { - return blockValueText( - value, - rule.resultTypeField ?? "type", - rule.resultTextTypes ?? DEFAULT_RESULT_TEXT_TYPES, - rule.resultTextField ?? "text", - ); - } - if (typeof value === "object") { - if (typeof value.text === "string") return value.text.trim(); - if (Array.isArray(value.content)) return resultToText(value.content, rule); - return asText(value); - } - return asText(value); -} - -function blocksAt(target, dotted) { - const v = getPath(target, dotted); - return Array.isArray(v) ? v : []; -} - -/** Does this entry carry one of the given types? */ -function typeMatches(target, rule) { - if (!rule || !Array.isArray(rule.types) || !rule.types.length) return false; - const t = getPath(target, rule.typePath || "type"); - return t != null && rule.types.includes(t); -} - -/** Omit `roles` (or pass an empty list) to apply a rule to every role. */ -function roleAllowed(role, rule) { - if (!rule || !Array.isArray(rule.roles) || !rule.roles.length) return true; - return rule.roles.includes(role); -} - -function toolArgs(target, rule) { - if (!rule || !rule.argsPath) return null; - const raw = getPath(target, rule.argsPath); - if (rule.argsJson && typeof raw === "string") { - try { - return JSON.parse(raw); - } catch { - return raw; - } - } - return raw ?? null; -} - -/** - * Follow an externalized result back to disk. - * - * Some agents truncate a large tool result and leave a pointer instead: - * " Output too large (254.6KB). - * Full output saved to: /…/projects//tool-output.txt" - * `follow: { marker: "Full output saved to:", maxBytes }` reads that file so - * the import keeps the real output. - * - * The read is bounded and confined: the target must resolve inside the - * spec's own data root, so a spec can never pull in a file the scan could - * not already have opened. - */ -async function followResult(text, rule, ctx) { - if (!rule || !text || typeof rule.marker !== "string" || !rule.marker) return text; - const idx = text.indexOf(rule.marker); - if (idx < 0) return text; - const token = text.slice(idx + rule.marker.length).trim().split(/\s+/)[0]; - if (!token) return text; - if (!ctx?.root) return text; - const abs = path.resolve(token); - if (!isInside(abs, ctx.root)) return text; - const maxBytes = Number.isFinite(rule.maxBytes) - ? Math.max(1, rule.maxBytes) - : DEFAULT_FOLLOW_MAX_BYTES; - try { - const st = await fsp.stat(abs); - if (!st.isFile() || st.size > maxBytes) return text; - const full = await fsp.readFile(abs, "utf8"); - return full.trim() || text; - } catch { - return text; // unreadable pointer -> keep the pointer text - } -} - -function makeToolMessage({ id, name, args, text, status, createdAt }) { - const msg = { - role: "tool", - content: text, - toolName: name, - toolStatus: status, - toolArgs: args ?? null, - toolResult: text, - createdAt, - }; - // Only paired calls/results have an id; self-contained entries have none. - if (id != null) msg.toolCallId = String(id); - return msg; -} - -/** - * The object all spec paths resolve against. - * - * Agents that wrap items in an envelope (Codex: - * `{timestamp, type, payload}`) would otherwise force every path in the spec - * to be prefixed. `unwrapPath` lifts the inner object when it is present and - * leaves bare (older-format) entries untouched, so one spec covers both. - */ -function resolveTarget(entry, spec) { - if (!spec.unwrapPath) return entry; - const inner = getPath(entry, spec.unwrapPath); - return inner && typeof inner === "object" && !Array.isArray(inner) ? inner : entry; -} - -/** - * Map one entry to 0..n messages. - * @param {object} entrySpec - * @param {object} entry - * @param {Map} pending - * @param {{root?: string}} [ctx] data root, needed by `toolCall.*.follow` - */ -async function mapEntry(entrySpec, entry, pending, ctx) { - const spec = entrySpec ?? {}; - if (!entry || typeof entry !== "object") return []; - - const target = resolveTarget(entry, spec); - - // Only map entries of the configured kind (conversation lines, kept items). - if (spec.match) { - const v = getPath(target, spec.match.path); - if (!Array.isArray(spec.match.in) || !spec.match.in.includes(v)) return []; - } - - if (spec.skipTypePath && Array.isArray(spec.skipTypes) && spec.skipTypes.length) { - const t = getPath(target, spec.skipTypePath); - if (t != null && spec.skipTypes.includes(t)) return []; - } - - // Envelope fields (timestamps live outside the payload) win over inner ones. - const ts = toIso(getPath(entry, spec.tsPath) ?? getPath(target, spec.tsPath), spec.tsUnit); - const role = mapRole(extractValue(spec.rolePath, target), spec.roleMap); - - // 1) self-contained tool entry: the entry carries call and result. - const tool = spec.tool; - if (tool && Array.isArray(tool.toolTypes) && tool.toolTypes.length) { - const t = getPath(target, tool.typePath || "type"); - if (tool.toolTypes.includes(t)) { - const result = extractText(tool.resultPath, target); - return [ - makeToolMessage({ - id: tool.idPath ? getPath(target, tool.idPath) : undefined, - name: extractText(tool.namePath, target) || "tool", - args: extractValue(tool.argsPath, target) ?? null, - text: result, - status: mapToolStatus(extractValue(tool.statusPath, target)), - createdAt: ts, - }), - ]; - } - } - - const tc = spec.toolCall; - if (tc && pending) { - // 2) entry-level call: remember it, emit nothing (the result carries it). - if (typeMatches(target, tc.call) && roleAllowed(role, tc.call)) { - const id = getPath(target, tc.call.idPath); - if (id != null) { - pending.set(String(id), { - name: String(getPath(target, tc.call.namePath) ?? "tool"), - args: toolArgs(target, tc.call), - createdAt: ts, - }); - } - return []; - } - - // 3) entry-level result: emit, merging whatever the call recorded. - if (typeMatches(target, tc.result) && roleAllowed(role, tc.result)) { - const id = getPath(target, tc.result.idPath); - if (id != null) { - const p = pending.get(String(id)); - pending.delete(String(id)); - const text = await followResult( - resultToText( - tc.result.resultPath ? getPath(target, tc.result.resultPath) : null, - tc.result, - ), - tc.result.follow, - ctx, - ); - const name = - (tc.result.namePath && getPath(target, tc.result.namePath)) || p?.name || "tool"; - return [ - makeToolMessage({ - id, - name: String(name), - args: p?.args ?? null, - text, - status: resultStatus( - tc.result.statusPath ? getPath(target, tc.result.statusPath) : undefined, - tc.result.errorValues, - ), - createdAt: ts, - }), - ]; - } - return []; - } - - // 4) block-level call: remember each tool_use, keep the message's text. - if (tc.callBlocks && roleAllowed(role, tc.callBlocks)) { - const cb = tc.callBlocks; - for (const b of blocksAt(target, cb.path)) { - if (!b || typeof b !== "object") continue; - if (getPath(b, cb.typeField) !== cb.type) continue; - const id = getPath(b, cb.idPath); - if (id == null) continue; - pending.set(String(id), { - name: String(getPath(b, cb.namePath) ?? "tool"), - args: cb.argsPath ? getPath(b, cb.argsPath) ?? null : null, - createdAt: ts, - }); - } - } - - // 5) block-level results: emit one tool message each and suppress the - // entry's text, exactly like the built-in Claude adapter does. - if (tc.resultBlocks && roleAllowed(role, tc.resultBlocks)) { - const rb = tc.resultBlocks; - const out = []; - for (const b of blocksAt(target, rb.path)) { - if (!b || typeof b !== "object") continue; - if (getPath(b, rb.typeField) !== rb.type) continue; - const id = getPath(b, rb.idPath); - const p = id != null ? pending.get(String(id)) : undefined; - if (id != null) pending.delete(String(id)); - const text = await followResult( - resultToText(rb.resultPath ? getPath(b, rb.resultPath) : null, rb), - rb.follow, - ctx, - ); - const name = (rb.namePath && getPath(b, rb.namePath)) || p?.name || "tool"; - out.push( - makeToolMessage({ - id, - name: String(name), - args: p?.args ?? null, - text, - status: resultStatus( - rb.statusPath ? getPath(b, rb.statusPath) : undefined, - rb.errorValues, - ), - createdAt: ts, - }), - ); - } - if (out.length) return out; - } - } - - // 6) ordinary conversation message. - if (!TEXT_ROLES.has(role)) return []; - const text = applyTextOps(extractText(spec.content, target), spec.textOps, role); - if (!text) return []; - if (droppedBy(spec.drop, text, role)) return []; - return [{ role, content: text, createdAt: ts }]; -} - -/** Map a whole transcript/session body to messages. */ -async function mapEntries(entrySpec, entries, ctx) { - const messages = []; - if (!Array.isArray(entries)) return messages; - const pending = new Map(); - for (const entry of entries) { - for (const msg of await mapEntry(entrySpec, entry, pending, ctx)) messages.push(msg); - } - // A call with no result is dropped by default because that is what the - // built-in adapters do. `emitUnpaired: true` keeps it as a running call. - if (entrySpec?.toolCall?.emitUnpaired) { - for (const p of pending.values()) { - messages.push( - makeToolMessage({ - id: undefined, - name: p.name, - args: p.args ?? null, - text: "", - status: "running", - createdAt: p.createdAt, - }), - ); - } - pending.clear(); - } - return messages; -} - -function firstUserText(messages) { - for (const m of messages) { - if (m.role === "user" && m.content) return m.content; - } - return ""; -} - -module.exports = { - mapEntry, - mapEntries, - mapToolStatus, - resultStatus, - resultToText, - firstUserText, - blockValueText, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/extract.js b/plugins/io.github.muzimu217.session-import/lib/drivers/extract.js deleted file mode 100644 index 4686198..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/extract.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Declarative value extraction used by every format driver. - * - * Drivers never embed app-specific logic; a spec describes *where* to look - * (dotted path / block array / first-non-empty / literal) and these helpers - * do the walking. That is what lets a new source be added as JSON instead of - * a new .js adapter. - */ -"use strict"; - -/** Resolve a dotted path ("a.b.c" or "a.0.b") against an object. */ -function getPath(obj, dotted) { - if (obj == null || !dotted) return undefined; - const parts = String(dotted).split(".").filter(Boolean); - let cur = obj; - for (const p of parts) { - if (cur == null) return undefined; - cur = Array.isArray(cur) ? cur[Number(p)] : cur[p]; - } - return cur; -} - -const isNonEmptyStr = (v) => typeof v === "string" && v.trim().length > 0; - -/** Coerce any value to display text (objects/arrays -> JSON). */ -function asText(v) { - if (v == null) return ""; - if (typeof v === "string") return v; - if (typeof v === "number" || typeof v === "boolean") return String(v); - try { - return JSON.stringify(v); - } catch { - return ""; - } -} - -/** - * Extract text from one entry via a declarative rule. Accepted shapes: - * "a.b" -> value at that path - * { path: "a.b" } -> value at that path - * { literal: "x" } -> constant - * { first: [rule, rule, ...] } -> first non-empty result - * { blocks: { path, typeField, types, textField } } - * -> array at path; keep items whose typeField is in types (or all - * when types is empty); join their textField (or the item itself). - */ -function extractText(rule, entry) { - if (!rule) return ""; - if (typeof rule === "string") return asText(getPath(entry, rule)); - if (typeof rule.literal === "string") return rule.literal; - if (Array.isArray(rule.first)) { - for (const sub of rule.first) { - const v = extractText(sub, entry); - if (v) return v; - } - return ""; - } - if (rule.blocks) { - const arr = getPath(entry, rule.blocks.path); - // Older entries carry content as a plain string — that IS the text. - if (typeof arr === "string") return arr; - // Never fall back to stringifying the array: content blocks may hold - // thinking/tool_use payloads, and dumping their raw JSON into the - // conversation is worse than emitting nothing. - if (!Array.isArray(arr)) return ""; - const texts = []; - for (const item of arr) { - if (item == null) continue; - if (typeof item !== "object") { - texts.push(asText(item)); - continue; - } - const t = rule.blocks.typeField ? getPath(item, rule.blocks.typeField) : undefined; - if (Array.isArray(rule.blocks.types) && rule.blocks.types.length) { - if (!rule.blocks.types.includes(t)) continue; - } - const txt = rule.blocks.textField ? getPath(item, rule.blocks.textField) : item; - if (isNonEmptyStr(txt)) texts.push(txt); - else if (txt != null) texts.push(asText(txt)); - } - return texts.join("\n").trim(); - } - if (rule.path) return asText(getPath(entry, rule.path)); - return ""; -} - -/** Extract a raw (unstringified) value — used for roles, ids, tool payloads. */ -function extractValue(rule, entry) { - if (!rule) return undefined; - if (typeof rule === "string") return getPath(entry, rule); - if (rule.path) return getPath(entry, rule.path); - return undefined; -} - -/** Map a raw role through a spec's roleMap, defaulting to the raw value. */ -function mapRole(raw, roleMap) { - const key = raw == null ? "" : String(raw); - if (roleMap && Object.prototype.hasOwnProperty.call(roleMap, key)) { - return String(roleMap[key]); - } - return key; -} - -/** Normalize a timestamp (ms | s | iso string) to an ISO string. */ -function toIso(value, unit) { - if (value == null || value === "") return null; - if (typeof value === "number" && Number.isFinite(value)) { - const ms = unit === "s" ? value * 1000 : value; - const d = new Date(ms); - return Number.isNaN(d.getTime()) ? null : d.toISOString(); - } - if (typeof value === "string") { - const n = Number(value); - if (/^\d+$/.test(value.trim()) && Number.isFinite(n)) { - const ms = unit === "s" ? n * 1000 : n; - const d = new Date(ms); - return Number.isNaN(d.getTime()) ? null : d.toISOString(); - } - const d = new Date(value); - return Number.isNaN(d.getTime()) ? null : d.toISOString(); - } - return null; -} - -/** - * Whitelisted text post-processing. - * - * Several agents wrap injected context in XML-ish tags (WorkBuddy: - * , ; Codex: markdown instructions). Stripping - * them is a pure string operation over already-extracted text, so a spec can - * ask for it without ever supplying code. Tag names are charset-checked and - * count-capped before they reach a RegExp. - */ -const MAX_TEXT_OPS = 8; -const MAX_TEXT_OP_TAGS = 16; -const TAG_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/; - -function stripXmlBlocks(text, tags) { - let out = text; - for (const tag of Array.isArray(tags) ? tags.slice(0, MAX_TEXT_OP_TAGS) : []) { - if (typeof tag !== "string" || !TAG_RE.test(tag)) continue; - out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?`, "gi"), ""); - out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*$`, "i"), ""); - } - return out; -} - -function extractXmlTag(text, tag) { - if (typeof tag !== "string" || !TAG_RE.test(tag)) return text; - const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)`, "i")); - return m ? m[1].trim() : text; -} - -/** - * Apply a spec's `textOps` to extracted text. - * [{ op:"stripXmlBlocks", tags:[...], roles:["user"] }, - * { op:"extractXmlTag", tag:"user_query", roles:["user"] }] - * `roles` restricts an op to a mapped role; omit it to apply to every role. - */ -function applyTextOps(text, ops, role) { - if (!text || !Array.isArray(ops) || !ops.length) return text; - let out = text; - let applied = 0; - for (const op of ops) { - if (applied >= MAX_TEXT_OPS) break; - if (!op || typeof op !== "object") continue; - if (Array.isArray(op.roles) && op.roles.length && !op.roles.includes(role)) continue; - applied += 1; - const name = String(op.op ?? ""); - if (name === "stripXmlBlocks") out = stripXmlBlocks(out, op.tags); - else if (name === "extractXmlTag") out = extractXmlTag(out, op.tag); - } - return out.trim(); -} - -/** Is this text dropped by a spec's `drop` rule? */ -function droppedBy(rule, text, role) { - if (!rule || typeof rule !== "object") return false; - if (Array.isArray(rule.roles) && rule.roles.length && !rule.roles.includes(role)) { - return false; - } - if (Array.isArray(rule.startsWith) && rule.startsWith.length) { - for (const p of rule.startsWith) { - if (typeof p === "string" && p && text.startsWith(p)) return true; - } - } - if (Array.isArray(rule.equals) && rule.equals.length && rule.equals.includes(text)) { - return true; - } - return false; -} - -module.exports = { - getPath, - asText, - extractText, - extractValue, - mapRole, - toIso, - applyTextOps, - droppedBy, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/fsutil.js b/plugins/io.github.muzimu217.session-import/lib/drivers/fsutil.js deleted file mode 100644 index 0690a7e..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/fsutil.js +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Filesystem helpers shared by the format drivers. - * - * Every driver walks user-configured paths, so all traversal here is - * best-effort and bounded: `~` expansion, absolute resolution, a hard cap on - * how many files are collected, and a per-file size ceiling so a huge or - * accidentally-overlapping root cannot stall a scan (the plugin's watchdog - * timeouts are the outer backstop). - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const fsp = require("node:fs/promises"); - -// Bounded defaults: a user-configured root must never be able to turn a scan -// into an unbounded filesystem walk. Specs may override, but these are the -// safety rails applied when they do not. -const DEFAULT_MAX_FILES = 2000; -const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MiB - -function expandHome(p, home = os.homedir()) { - if (typeof p !== "string" || p === "") return p; - if (p === "~") return home; - if (p.startsWith("~/")) return path.join(home, p.slice(2)); - return p; -} - -const EXTENSION_RE = /^\.[A-Za-z0-9]{1,16}$/; - -/** True for POSIX `/` and Windows drive roots such as `C:\`. */ -function isFilesystemRoot(p) { - if (typeof p !== "string" || !p) return false; - const n = path.normalize(p); - if (n === path.sep) return true; - if (/^[A-Za-z]:[\\/]?$/.test(n) || /^[A-Za-z]:[\\/]?$/.test(p.trim())) return true; - const root = path.parse(n).root; - return Boolean(root) && n === path.normalize(root); -} - -function resolveSafe(p, home = os.homedir()) { - const expanded = expandHome(p, home); - return path.normalize(path.isAbsolute(expanded) ? expanded : path.resolve(expanded)); -} - -function isInside(file, root) { - if (typeof file !== "string" || typeof root !== "string" || !file || !root) return false; - const a = path.resolve(file); - const b = path.resolve(root); - return a === b || a.startsWith(b + path.sep); -} - -/** True when the path exists (file or dir). */ -async function exists(p) { - try { - await fsp.access(p); - return true; - } catch { - return false; - } -} - -/** - * Recursively collect files under `root` filtered by extension. - * Bounded by `max` so a broad root cannot explode the scan. - */ -async function listFiles(root, options = {}) { - const { - extension = ".jsonl", - recursive = true, - max = DEFAULT_MAX_FILES, - maxBytes = DEFAULT_MAX_FILE_BYTES, - // Depth limit counted from the root (root itself is depth 1, so - // maxDepth:2 matches `//` and never descends further). - // Apps like Claude Code keep non-session data (e.g. .timelines) in - // deeper subdirectories, so unbounded recursion picks up junk. - maxDepth = Infinity, - } = options; - // Empty/`*` extensions would match every file. Require a real suffix. - if (typeof extension !== "string" || !EXTENSION_RE.test(extension)) return []; - const out = []; - const depthCap = Number.isFinite(maxDepth) ? Math.max(1, Math.trunc(maxDepth)) : Infinity; - - const walk = async (dir, depth) => { - if (out.length >= max) return; - let entries; - try { - entries = await fsp.readdir(dir, { withFileTypes: true }); - } catch { - return; // unreadable dir -> skip, don't fail the whole scan - } - for (const entry of entries) { - if (out.length >= max) return; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (recursive && depth < depthCap) await walk(full, depth + 1); - continue; - } - if (!entry.isFile()) continue; - if (extension && !entry.name.endsWith(extension)) continue; - out.push(full); - } - }; - - await walk(root, 1); - - if (!maxBytes) return out; - // Drop files too large to parse safely in one go. - const kept = []; - for (const file of out) { - try { - const st = await fsp.stat(file); - if (st.size <= maxBytes) kept.push(file); - } catch { - /* unreadable -> drop */ - } - } - return kept; -} - -/** Read a text file, or null when unreadable/oversized. */ -async function readText(filePath, maxBytes = DEFAULT_MAX_FILE_BYTES) { - try { - const st = await fsp.stat(filePath); - if (st.size > maxBytes) return null; - return await fsp.readFile(filePath, "utf8"); - } catch { - return null; - } -} - -/** Split raw text into parsed JSON objects, ignoring blank/malformed lines. */ -function parseLines(raw, maxLines = 20000) { - const out = []; - if (!raw) return out; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed)); - } catch { - /* skip malformed line */ - } - if (out.length >= maxLines) break; - } - return out; -} - -module.exports = { - DEFAULT_MAX_FILES, - DEFAULT_MAX_FILE_BYTES, - EXTENSION_RE, - expandHome, - isFilesystemRoot, - isInside, - resolveSafe, - exists, - listFiles, - readText, - parseLines, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/index.js b/plugins/io.github.muzimu217.session-import/lib/drivers/index.js deleted file mode 100644 index 5795989..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/index.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Driver registry — the half of the extensibility layer that is code. - * - * Adding a *new app* does not touch this file: it only needs a spec (JSON) - * naming one of the registered drivers. This file only grows when a genuinely - * new on-disk format appears (a 4th driver), which is the rare case. - */ -"use strict"; - -const jsonlTranscript = require("./jsonl-transcript"); -const sqliteSession = require("./sqlite-session"); -const jsonTree = require("./json-tree"); - -const DRIVERS = { - "jsonl-transcript": jsonlTranscript, - "sqlite-session": sqliteSession, - "json-tree": jsonTree, -}; - -function getDriver(name) { - return DRIVERS[name] ?? null; -} - -function driverNames() { - return Object.keys(DRIVERS); -} - -module.exports = { DRIVERS, getDriver, driverNames }; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/json-tree.js b/plugins/io.github.muzimu217.session-import/lib/drivers/json-tree.js deleted file mode 100644 index 492e373..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/json-tree.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * json-tree driver - * - * One JSON file per session, with the conversation nested inside it (the - * legacy Claude/OpenCode per-file layout, and the shape plenty of tools use). - * - * Spec: - * root, extension (default .json), recursive, maxFiles, maxBytes - * session: { idPath, titlePath, tsPath, pathPath, messagesPath, - * fallbackProject } - * message: { rolePath, roleMap, content:rule, tsPath, tsUnit, - * skipTypePath, skipTypes[], tool:{...} } - * (the message block is the same declarative mapping the JSONL driver uses, - * provided by ../drivers/entry-map) - */ -"use strict"; - -const path = require("node:path"); -const fsp = require("node:fs/promises"); -const { toIso, truncateTitle, projectNameOf } = require("../util"); -const { getPath, toIso: tsToIso } = require("./extract"); -const { resolveSafe, listFiles, readText, isInside } = require("./fsutil"); -const { mapEntries } = require("./entry-map"); - -const DRIVER = "json-tree"; - -function readJson(raw) { - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -async function loadSession(spec, file) { - const raw = await readText(file, spec.maxBytes); - const doc = readJson(raw); - if (!doc || typeof doc !== "object") return null; - const s = spec.session ?? {}; - const messages = await mapEntries( - spec.message ?? {}, - getPath(doc, s.messagesPath ?? "messages") ?? [], - { root: resolveSafe(spec.root) }, - ); - return { doc, messages }; -} - -async function scan(spec, sourceId) { - const root = resolveSafe(spec.root); - const files = await listFiles(root, { - extension: spec.extension ?? ".json", - recursive: spec.recursive !== false, - max: spec.maxFiles, - maxBytes: spec.maxBytes, - maxDepth: spec.maxDepth, - }); - const s = spec.session ?? {}; - - const summaries = []; - for (const file of files) { - const loaded = await loadSession(spec, file); - if (!loaded) continue; - const { doc, messages } = loaded; - if (!messages.length) continue; - - const base = path.basename(file, path.extname(file)); - const externalId = String(getPath(doc, s.idPath ?? "id") ?? base); - const title = String(getPath(doc, s.titlePath ?? "title") ?? "") || base; - const projectPath = getPath(doc, s.pathPath ?? "directory") ?? path.dirname(file); - const ts = tsToIso(getPath(doc, s.tsPath ?? "createdAt"), spec.tsUnit); - - let mtime = null; - try { - mtime = toIso((await fsp.stat(file)).mtimeMs); - } catch { - mtime = null; - } - - summaries.push({ - source: sourceId, - externalId, - title: truncateTitle(title), - fullTitle: title, - projectName: projectNameOf(projectPath) ?? s.fallbackProject ?? null, - projectPath: typeof projectPath === "string" ? projectPath : null, - modelId: null, - providerId: null, - createdAt: ts || mtime, - updatedAt: ts || mtime, - messageCount: messages.length, - filePath: file, - }); - } - return summaries; -} - -async function convert(spec, summary) { - const root = resolveSafe(spec.root); - const file = typeof summary.filePath === "string" ? path.resolve(summary.filePath) : ""; - if (!file || !isInside(file, root)) return { session: null, messages: [] }; - const loaded = await loadSession(spec, file); - if (!loaded) return { session: null, messages: [] }; - - return { - session: { - id: `import-${summary.source}-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath ?? null, - modelId: null, - providerId: null, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages: loaded.messages, - }; -} - -module.exports = { driver: DRIVER, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/jsonl-transcript.js b/plugins/io.github.muzimu217.session-import/lib/drivers/jsonl-transcript.js deleted file mode 100644 index 1c1a36a..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/jsonl-transcript.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * jsonl-transcript driver - * - * Reads "one JSON object per line" transcripts (the layout Claude Code, - * Codex, WorkBuddy and Pi all use). Every app quirk is expressed in the spec - * — role paths, content blocks, tool envelopes, timestamp units — so a new - * JSONL source needs config, not code. - * - * Spec: - * root, extension, recursive, maxFiles, maxBytes - * session: { idFrom:"filename"|rule, titleFrom:"firstUser"|rule, - * projectFrom:"parentDir"|rule, fallbackProject } - * entry: { rolePath, roleMap, content:rule, tsPath, tsUnit, - * skipTypePath, skipTypes[], tool:{ typePath, toolTypes[], - * namePath, argsPath, resultPath, statusPath } } - */ -"use strict"; - -const path = require("node:path"); -const fsp = require("node:fs/promises"); -const { toIso, truncateTitle, projectNameOf } = require("../util"); -const { extractText, getPath } = require("./extract"); -const { resolveSafe, listFiles, readText, parseLines, isInside } = require("./fsutil"); -const { mapEntries, firstUserText } = require("./entry-map"); - -const DRIVER = "jsonl-transcript"; - -function sessionTitle(sessionSpec, messages, file) { - const from = sessionSpec?.titleFrom; - if (from === "firstUser") { - return truncateTitle(firstUserText(messages)) || path.basename(file, path.extname(file)); - } - if (from && from !== "filename") { - const t = extractText(from, messages[0]?.__raw ?? {}) || ""; - if (t) return truncateTitle(t); - } - return path.basename(file, path.extname(file)); -} - -async function scan(spec, sourceId) { - const root = resolveSafe(spec.root); - const files = await listFiles(root, { - extension: spec.extension ?? ".jsonl", - recursive: spec.recursive !== false, - max: spec.maxFiles, - maxBytes: spec.maxBytes, - maxDepth: spec.maxDepth, - }); - const entrySpec = spec.entry ?? {}; - const sessionSpec = spec.session ?? {}; - - const summaries = []; - for (const file of files) { - const raw = await readText(file, spec.maxBytes); - if (raw == null) continue; - const entries = parseLines(raw, spec.maxLines); - if (!entries.length) continue; - - const messages = await mapEntries(entrySpec, entries, { root }); - if (!messages.length) continue; - - const ext = path.extname(file); - const base = path.basename(file, ext); - let externalId = base; - if (sessionSpec.idFrom && sessionSpec.idFrom !== "filename") { - // The id is not always on the first line (Codex puts it in a later - // session-meta event), so fall back to scanning the whole transcript. - // `idFromEntry` restricts the first pass to the entry kind that actually - // carries the session id: other entries may own an `id` of their own - // (every Codex response_item does), and picking one of those would - // produce a transcript id that matches nothing. - const pick = (list) => { - for (const e of list) { - const v = extractText(sessionSpec.idFrom, e); - if (v) return v; - } - return ""; - }; - const want = sessionSpec.idFromEntry; - let v = ""; - if (want && Array.isArray(want.in) && want.in.length) { - v = pick(entries.filter((e) => want.in.includes(getPath(e, want.path)))); - } - if (!v) v = pick(entries); - if (v) externalId = v; - } - - const projectPath = path.dirname(file); - const projectName = - (sessionSpec.projectFrom === "parentDir" || !sessionSpec.projectFrom - ? projectNameOf(projectPath) - : extractText(sessionSpec.projectFrom, entries[0])) || - sessionSpec.fallbackProject || - null; - - let mtime = null; - try { - mtime = toIso((await fsp.stat(file)).mtimeMs); - } catch { - mtime = null; - } - - const createdAt = messages[0]?.createdAt || mtime; - const updatedAt = messages[messages.length - 1]?.createdAt || mtime || createdAt; - - summaries.push({ - source: sourceId, - externalId, - title: truncateTitle(sessionTitle(sessionSpec, messages, file)), - fullTitle: sessionTitle(sessionSpec, messages, file), - projectName, - projectPath, - modelId: null, - providerId: null, - createdAt, - updatedAt, - messageCount: messages.length, - filePath: file, - }); - } - return summaries; -} - -async function convert(spec, summary) { - const root = resolveSafe(spec.root); - const file = typeof summary.filePath === "string" ? path.resolve(summary.filePath) : ""; - if (!file || !isInside(file, root)) return { session: null, messages: [] }; - const raw = await readText(file, spec.maxBytes); - if (raw == null) return { session: null, messages: [] }; - const entries = parseLines(raw, spec.maxLines); - const messages = await mapEntries(spec.entry ?? {}, entries, { - root: resolveSafe(spec.root), - }); - - return { - session: { - id: `import-${summary.source}-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath ?? null, - modelId: null, - providerId: null, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; -} - -module.exports = { driver: DRIVER, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/drivers/sqlite-session.js b/plugins/io.github.muzimu217.session-import/lib/drivers/sqlite-session.js deleted file mode 100644 index 1683401..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/drivers/sqlite-session.js +++ /dev/null @@ -1,326 +0,0 @@ -/** - * sqlite-session driver - * - * Reads the three-layer on-disk model shared by ZCode and OpenCode: - * session -> message -> part, with per-row payloads in a JSON `data` column. - * Column names and JSON paths come from the spec, so any app using this shape - * (or a simple session+message pair) is configurable rather than hand-coded. - * - * Spec: - * db, maxSessions - * session: { table, idCol, titleCol, pathCol, createdCol, updatedCol } - * message: { table, idCol, sessionIdCol, createdCol, dataCol, - * rolePath, tsPath, modelIdPath, providerIdPath } - * part: { table, messageIdCol, sessionIdCol, createdCol, dataCol, - * textTypes[], toolType, toolNamePath, argsPath, resultPath, - * statusPath, skipSynthetic } - * Omit `part` entirely for apps that store message text directly (set - * message.contentPath instead). - */ -"use strict"; - -const { toIso, truncateTitle, projectNameOf } = require("../util"); -const { extractValue, getPath, asText, toIso: tsToIso } = require("./extract"); -const path = require("node:path"); -const { resolveSafe } = require("./fsutil"); - -const DRIVER = "sqlite-session"; - -function openDb(dbPath) { - const { DatabaseSync } = require("node:sqlite"); - return new DatabaseSync(dbPath, { readOnly: true }); -} - -function parseJsonColumn(raw) { - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -function mapToolStatus(status) { - const s = status == null ? "" : String(status); - if (s === "error" || s === "failed") return "error"; - if (s === "completed" || s === "success") return "success"; - return "running"; -} - -const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -/** Quote an identifier for safe interpolation into SQL (columns can't be bound). */ -function ident(name) { - const s = String(name ?? ""); - if (!IDENT_RE.test(s) || s.length > 64) { - throw new Error(`invalid SQL identifier: ${s}`); - } - return `"${s}"`; -} - -function tsValue(row, col, fallback, unit) { - return tsToIso(row?.[col] ?? fallback, unit) ?? toIso(row?.[col] ?? fallback) ?? null; -} - -/** - * Build a structured exclusion filter, e.g. - * exclude: [{ col: "task_type", equals: "subagent_child" }] - * - * Values are bound (never interpolated) and the column is checked against the - * real table schema, so a spec can filter rows without ever supplying SQL. - * Unknown columns are ignored rather than crashing the scan. - * IFNULL(...,'') keeps rows whose column is NULL. - */ -/** Column names of a table, or an empty set when the table is missing. */ -function tableColumns(db, table) { - try { - return new Set(db.prepare(`PRAGMA table_info(${ident(table)})`).all().map((r) => r.name)); - } catch { - return new Set(); - } -} - -/** - * Build an ORDER BY that follows the app's own ordering. - * - * ZCode/OpenCode disagree here: ZCode writes a monotonic `sequence` column and - * its built-in adapter sorts by `sequence, time_created, id`, while OpenCode - * has no such column at all. Ordering by time alone reorders parts that were - * written in the same millisecond, so the column is used whenever it exists. - */ -function buildOrderBy(db, table, cols) { - const have = tableColumns(db, table); - const parts = []; - for (const c of cols) { - if (c && have.has(c)) parts.push(ident(c)); - } - return parts.length ? ` ORDER BY ${parts.join(", ")}` : ""; -} - -function buildExclusionClause(db, table, exclude) { - const out = { sql: "", params: [] }; - if (!Array.isArray(exclude) || !exclude.length) return out; - const cols = tableColumns(db, table); - for (const rule of exclude) { - if (!rule || typeof rule.col !== "string" || !cols.has(rule.col)) continue; - const c = ident(rule.col); - if (Array.isArray(rule.in) && rule.in.length) { - const ph = rule.in.map(() => "?").join(","); - out.sql += ` AND IFNULL(${c},'') NOT IN (${ph})`; - out.params.push(...rule.in.map(String)); - } else if (rule.equals !== undefined && rule.equals !== null) { - out.sql += ` AND IFNULL(${c},'') <> ?`; - out.params.push(String(rule.equals)); - } - } - return out; -} - -async function scan(spec, sourceId) { - const dbPath = resolveSafe(spec.db); - let db; - try { - db = openDb(dbPath); - } catch { - return []; // DB absent / unreadable -> nothing to import - } - - const s = spec.session ?? {}; - const m = spec.message ?? {}; - try { - const sTable = ident(s.table ?? "session"); - const mTable = m.table ? ident(m.table) : null; - const limit = Number.isFinite(spec.maxSessions) ? ` LIMIT ${Math.max(1, spec.maxSessions | 0)}` : ""; - - const exclusion = buildExclusionClause(db, s.table ?? "session", s.exclude); - let rows; - if (mTable) { - rows = db - .prepare( - `SELECT s.${ident(s.idCol ?? "id")} AS sid, - s.${ident(s.titleCol ?? "title")} AS title, - s.${ident(s.pathCol ?? "directory")} AS directory, - s.${ident(s.createdCol ?? "time_created")} AS created, - s.${ident(s.updatedCol ?? "time_updated")} AS updated, - COALESCE(m.message_count, 0) AS message_count - FROM ${sTable} s - LEFT JOIN ( - SELECT ${ident(m.sessionIdCol ?? "session_id")} AS sid, COUNT(*) AS message_count - FROM ${mTable} - GROUP BY ${ident(m.sessionIdCol ?? "session_id")} - ) m ON m.sid = s.${ident(s.idCol ?? "id")} - WHERE m.message_count > 0${exclusion.sql}${limit}`, - ) - .all(...exclusion.params); - } else { - rows = db - .prepare( - `SELECT ${ident(s.idCol ?? "id")} AS sid, - ${ident(s.titleCol ?? "title")} AS title, - ${ident(s.pathCol ?? "directory")} AS directory, - ${ident(s.createdCol ?? "time_created")} AS created, - ${ident(s.updatedCol ?? "time_updated")} AS updated, - 0 AS message_count - FROM ${sTable} WHERE 1=1${exclusion.sql}${limit}`, - ) - .all(...exclusion.params); - } - - const summaries = []; - for (const row of rows) { - if (!row.sid) continue; - const created = tsToIso(row.created, spec.tsUnit) ?? toIso(row.created); - const updated = tsToIso(row.updated, spec.tsUnit) ?? toIso(row.updated, created); - const title = String(row.title ?? ""); - summaries.push({ - source: sourceId, - externalId: String(row.sid), - title: truncateTitle(title) || String(row.sid), - fullTitle: title, - projectName: projectNameOf(row.directory) ?? (spec.fallbackProject || null), - projectPath: row.directory ?? null, - modelId: null, - providerId: null, - createdAt: created, - updatedAt: updated || created, - messageCount: Number(row.message_count ?? 0), - filePath: dbPath, - }); - } - return summaries; - } finally { - db.close(); - } -} - -async function convert(spec, summary) { - const dbPath = resolveSafe(spec.db); - if (typeof summary.filePath === "string" && path.resolve(summary.filePath) !== dbPath) { - return { session: null, messages: [] }; - } - const db = openDb(dbPath); - const m = spec.message ?? {}; - const p = spec.part ?? null; - const msgTable = ident(m.table ?? "message"); - const idCol = ident(m.idCol ?? "id"); - const sidCol = ident(m.sessionIdCol ?? "session_id"); - const msgOrder = buildOrderBy(db, m.table ?? "message", [ - m.sequenceCol === null ? null : m.sequenceCol ?? "sequence", - m.createdCol ?? "time_created", - m.idCol ?? "id", - ]); - - try { - const messageRows = db - .prepare( - `SELECT ${idCol} AS mid, ${ident(m.createdCol ?? "time_created")} AS created, - ${ident(m.dataCol ?? "data")} AS data - FROM ${msgTable} - WHERE ${sidCol} = ?${msgOrder}`, - ) - .all(summary.externalId); - - const partsByMessage = new Map(); - if (p) { - const partTable = p.table ?? "part"; - const partOrder = buildOrderBy(db, partTable, [ - p.sequenceCol === null ? null : p.sequenceCol ?? "sequence", - p.createdCol ?? "time_created", - "id", - ]); - const partRows = db - .prepare( - `SELECT ${ident(p.messageIdCol ?? "message_id")} AS mid, - ${ident(p.dataCol ?? "data")} AS data - FROM ${ident(partTable)} - WHERE ${ident(p.sessionIdCol ?? "session_id")} = ?${partOrder}`, - ) - .all(summary.externalId); - for (const row of partRows) { - const part = parseJsonColumn(row.data); - if (!part || typeof part !== "object" || !row.mid) continue; - const bucket = partsByMessage.get(row.mid) ?? []; - bucket.push(part); - partsByMessage.set(row.mid, bucket); - } - } - - const textTypes = p?.textTypes ?? ["text"]; - const toolType = p?.toolType ?? "tool"; - const skipSynthetic = p?.skipSynthetic !== false; - - const messages = []; - let modelId = null; - let providerId = null; - - const flushText = (role, createdAt, texts) => { - const text = texts.join("\n").trim(); - texts.length = 0; - if (!text) return; - messages.push({ role, content: text, createdAt }); - }; - - for (const row of messageRows) { - const msg = parseJsonColumn(row.data) ?? {}; - if (!row.mid) continue; - const role = String(getPath(msg, m.rolePath ?? "role") ?? ""); - if (role !== "user" && role !== "assistant") continue; - const createdAt = - tsToIso(getPath(msg, m.tsPath ?? "time.created"), spec.tsUnit) ?? toIso(row.created); - if (role === "assistant") { - modelId = getPath(msg, m.modelIdPath ?? "modelID") ?? modelId; - providerId = getPath(msg, m.providerIdPath ?? "providerID") ?? providerId; - } - - const parts = partsByMessage.get(row.mid) ?? []; - const texts = []; - for (const part of parts) { - const type = getPath(part, "type"); - if (type === toolType) { - flushText(role, createdAt, texts); - const output = extractValue(p?.resultPath ?? "state.output", part); - const outputText = typeof output === "string" ? output : asText(output); - messages.push({ - role: "tool", - content: outputText, - toolName: String(getPath(part, p?.toolNamePath ?? "tool") ?? "tool"), - toolStatus: mapToolStatus(extractValue(p?.statusPath ?? "state.status", part)), - toolArgs: extractValue(p?.argsPath ?? "state.input", part) ?? null, - toolResult: outputText, - createdAt, - }); - } else if (!textTypes.length || textTypes.includes(type)) { - if (skipSynthetic && getPath(part, "synthetic") === true) continue; - const text = getPath(part, "text"); - if (typeof text === "string" && text) texts.push(text); - } - } - flushText(role, createdAt, texts); - - // Apps without a part table keep text on the message row itself. - if (!p) { - const direct = m.contentPath ? getPath(msg, m.contentPath) : getPath(msg, "content"); - const text = typeof direct === "string" ? direct : asText(direct); - if (text) messages.push({ role, content: text, createdAt }); - } - } - - return { - session: { - id: `import-${summary.source}-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath ?? null, - modelId, - providerId, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; - } finally { - db.close(); - } -} - -module.exports = { driver: DRIVER, scan, convert, openDb }; diff --git a/plugins/io.github.muzimu217.session-import/lib/forge.js b/plugins/io.github.muzimu217.session-import/lib/forge.js deleted file mode 100644 index a9dad3a..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/forge.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Forge — 把导入的会话蒸馏成项目规则与可复用技能。 - * - * 为什么必须长在 session-import 这个 plugin_id 里(架构硬约束): - * host-core 的 `plugin_sessions::list` SQL 是 - * ... JOIN session_import_origins oi ON oi.session_id = s.id - * WHERE oi.plugin_id = ?1 - * 也就是说 `pi.session.list()` 只返回**本插件自己导入**的行。宿主原生产生的 - * 会话对任何插件都不可见。所以"读回并蒸馏"只能做在导入方插件内,独立插件 - * 拿不到数据源。 - * - * 这正是本模块难以被复刻的原因:它需要同时具备 - * 1. 六来源会话适配器(读取侧,lib/sources/*) - * 2. PI-Desktop 独有的 session.read.own + agent.complete 组合 - * 前者要逆向各家的磁盘格式,后者是 ADR 0200 / 0174 刚落地的 API。 - */ -"use strict"; - -/** agent.complete 的硬上限(ADR 0174)。 */ -const COMPLETE_LIMITS = { - systemMax: 32 * 1024, - messagesMaxChars: 200_000, - callsPerMinute: 8, -}; - -/** 滚动窗口限速:ADR 0174 规定每插件每 60s 最多 8 次。 */ -const callLog = []; - -function withinRateLimit() { - const now = Date.now(); - while (callLog.length > 0 && now - callLog[0] > 60_000) callLog.shift(); - if (callLog.length >= COMPLETE_LIMITS.callsPerMinute) { - const waitMs = 60_000 - (now - callLog[0]); - return { ok: false, retryAfterMs: waitMs }; - } - callLog.push(now); - return { ok: true }; -} - -/** - * 把一批会话压成一条适合 side completion 的语料。 - * 超出预算时按"每个会话均摊、优先保留尾部(结论通常出现在后面)"裁剪, - * 保证任何输入规模都不会撞上 200k 字符上限。 - */ -function buildCorpus(sessions, budgetChars = COMPLETE_LIMITS.messagesMaxChars) { - if (sessions.length === 0) return ""; - const perSession = Math.floor(budgetChars / sessions.length); - const parts = []; - - for (const session of sessions) { - const lines = [`## 会话:${session.title || "(无标题)"}`]; - if (session.projectPath) lines.push(`- 项目:${session.projectPath}`); - if (session.modelId) lines.push(`- 模型:${session.modelId}`); - lines.push(""); - - const messages = Array.isArray(session.messages) ? session.messages : []; - let used = lines.join("\n").length; - - for (const m of messages) { - const role = m.role === "assistant" ? "助手" : m.role === "tool" ? "工具" : "用户"; - const body = String(m.content ?? "").trim(); - if (!body) continue; - const chunk = `**${role}**: ${body}\n`; - if (used + chunk.length > perSession) break; // 保留更早的内容,尾部溢出即止 - lines.push(chunk); - used += chunk.length; - } - parts.push(lines.join("\n")); - } - - return parts.join("\n\n---\n\n"); -} - -const SYSTEM_PROMPT = `你是一位资深工程负责人,负责从一批真实的人机协作会话记录中提炼出可长期复用的知识。 - -输出要求: -1. 只提炼会话中**确实出现过**的模式:反复踩到的坑、被验证有效的做法、用户明确表达的偏好与约束。 -2. 不要编造会话里没有的事实。不确定的内容单独列入"待确认",不要混入正文。 -3. 用简体中文输出,技术术语与命令保留英文原文。 -4. 结构紧凑,条目化,可直接落地执行。不要客套话。 - -按以下结构输出: - -## 项目约定 -(从会话中体现出的、这个项目/用户遵循的规则。每条一行,祈使句。) - -## 已验证做法 -(在会话中被证明有效的具体方法、命令、参数或工作流。附简短依据。) - -## 反复出现的坑 -(多次踩到或代价高昂的错误,以及对应的规避方式。) - -## 待确认 -(证据不足、需要用户拍板的观察。)`; - -/** - * 调用宿主的一次性补全做蒸馏。 - * 凭据始终留在 Electron 主进程(ADR 0174),插件拿不到任何 key。 - */ -async function distill({ sessions, modelKey, thinkingLevel, goal }) { - const rate = withinRateLimit(); - if (!rate.ok) { - throw Object.assign(new Error("agent.complete 触发限速,请稍后重试"), { - code: "RATE_LIMITED", - retryAfterMs: rate.retryAfterMs, - }); - } - - const corpus = buildCorpus(sessions); - if (!corpus.trim()) throw new Error("没有可蒸馏的会话内容"); - - const userPrompt = [ - goal ? `本次提炼的重点:${goal}\n` : "", - `共 ${sessions.length} 个会话,记录如下:\n\n${corpus}`, - ] - .filter(Boolean) - .join("\n"); - - const result = await pi.agent.complete({ - ...(modelKey ? { modelKey } : {}), - ...(thinkingLevel ? { thinkingLevel } : {}), - system: SYSTEM_PROMPT, - messages: [{ role: "user", content: userPrompt }], - }); - - return { text: extractText(result), usage: result?.usage ?? null }; -} - -/** 兼容宿主可能返回的不同补全形状。 */ -function extractText(result) { - if (!result) return ""; - if (typeof result === "string") return result; - if (typeof result.text === "string") return result.text; - if (typeof result.content === "string") return result.content; - if (Array.isArray(result.content)) { - return result.content - .map((c) => (typeof c === "string" ? c : c?.text ?? "")) - .join(""); - } - return ""; -} - -/** 列出本插件导入过的会话(宿主按 plugin_id 隔离,只能看到自己的行)。 */ -async function listImported({ source, limit = 100 } = {}) { - const args = { limit: Math.min(Math.max(Number(limit) || 100, 1), 200) }; - if (source) args.source = source; - const res = await pi.session.list(args); - // 宿主(v0.14+)返回 { items: [...] };兼容旧形态 { sessions } 与裸数组。 - // 同时把 sessionId 投影到 id,下游(视图复选框 / 蒸馏查找)只认 id。 - const raw = Array.isArray(res) ? res : (res?.items ?? res?.sessions ?? []); - return raw.map((s) => ({ ...s, sessionId: s.sessionId ?? s.id, id: s.sessionId ?? s.id })); -} - -async function readMessages(sessionId, { limit = 500 } = {}) { - const res = await pi.session.listMessages({ sessionId, limit }); - return Array.isArray(res) ? res : (res?.items ?? res?.messages ?? []); -} - -module.exports = { - distill, - listImported, - readMessages, - buildCorpus, - COMPLETE_LIMITS, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/history.js b/plugins/io.github.muzimu217.session-import/lib/history.js deleted file mode 100644 index 4f2f672..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/history.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * lib/history.js — distillation history as a JSONL ring buffer. - * - * Pure (no host dependency). Persistence is handled by main.js through the - * host `pi.fs` API; this module only (de)serializes and caps the buffer. - * - * Stored at HISTORY_PATH (within the plugin's `fs.write`/`fs.read` scope: - * `docs/**`), one JSON object per line, newest last, capped at HISTORY_LIMIT. - */ -"use strict"; - -const HISTORY_LIMIT = 50; -const HISTORY_PATH = "docs/.session-import-history.jsonl"; - -/** - * Parse JSONL text into an array of entries. Malformed/blank lines are - * skipped so a partially-written file never crashes the loader. - * @param {string} text - * @returns {Array} - */ -function parseJsonl(text) { - if (!text) return []; - const out = []; - for (const line of String(text).split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed); - if (entry && typeof entry === "object") out.push(entry); - } catch { - // skip unparseable line - } - } - return out; -} - -/** Serialize one entry to a single JSON line. */ -function serializeEntry(entry) { - return JSON.stringify(entry); -} - -/** Serialize the full entry list back to JSONL text. */ -function toJsonl(entries) { - const body = entries.map(serializeEntry).join("\n"); - return body ? body + "\n" : ""; -} - -/** - * Append an entry and keep only the most recent HISTORY_LIMIT entries. - * @param {Array} entries current entries - * @param {object} entry entry to append - * @returns {Array} - */ -function addEntry(entries, entry) { - const next = [...(entries || []), entry]; - return next.slice(-HISTORY_LIMIT); -} - -module.exports = { - HISTORY_LIMIT, - HISTORY_PATH, - parseJsonl, - serializeEntry, - toJsonl, - addEntry, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/i18n.js b/plugins/io.github.muzimu217.session-import/lib/i18n.js deleted file mode 100644 index 70cd673..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/i18n.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * lib/i18n.js — view-side internationalization for Universal Session Import - * and Session Forge. - * - * Single source of truth for every user-facing string. Works two ways: - * - Node: `const { getMessage } = require("./lib/i18n")` (used by tests) - * - Browser: loaded via `` in the - * work-panel views, which attaches `window.SI18n`. - * - * getMessage(locale, key, params) resolves `key` from the `locale` table - * (falling back to zh-CN, then to the key itself), and interpolates - * `{name}` placeholders from `params`. - */ -(function (root, factory) { - const api = factory(); - if (typeof module === "object" && module.exports) module.exports = api; - if (typeof window !== "undefined") window.SI18n = api; -})(typeof self !== "undefined" ? self : this, function () { - "use strict"; - - const DICT = { - en: { - "app.title": "Universal Session Import", - "importer.heading": "Universal Session Import", - "importer.hint": "Scan the coding tools installed on this machine, pick a source, then select sessions to import.", - "importer.scan": "Scan local tools", - "importer.filter.placeholder": "Search title or project…", - "importer.selectAll": "Select all", - "importer.clearAll": "Clear", - "importer.listbox.label": "Session list", - "importer.previewEmpty": "Select a session on the left to preview it.", - "importer.import": "Import as sessions", - "importer.count.selected": "Selected {selected} / {total}", - "importer.status.scanning": "Scanning local tools…", - "importer.status.scanDone": "Scan complete: click a tool above to choose a source", - "importer.status.loadingList": "Loading session list…", - "importer.status.loadFailed": "Failed to load: {error}", - "importer.source.detecting": "Detecting…", - "importer.source.notFound": "Not found", - "importer.count.sessions": "{count} sessions", - "importer.count.scanningMore": "(still scanning…)", - "importer.group.noProject": "No project", - "importer.group.selectAll": "Select all in project", - "importer.group.deselectAll": "Deselect all", - "importer.preview.noProject": "No project", - "importer.preview.unknownModel": "Unknown model", - "importer.preview.Nmessages": "{count} messages", - "importer.preview.updatedAt": "Updated {date}", - "importer.status.loadedMessages": "Loaded {count} messages", - "importer.importing": "Importing {count} sessions…", - "importer.imported": "Imported {count} sessions", - "importer.skipped": "Skipped {count} duplicates/unreadable", - "importer.failed": "{count} failed", - "importer.importDone.timing": "in {total}s (read {convert}s · host write {host}s)", - "importer.importDone.done": "— grouped under their project; open the project or check the Projects page to view", - "importer.legacy.imported": "Imported {imported} sessions (skipped {skipped} duplicates/unreadable) — visible in the left project list immediately", - "importer.importFailed": "Import failed: {error}", - "importer.unreadable": "Skipped {count} unreadable", - "importer.hostHint": "Host does not expose a session-import API (session.import / importBatch). Run the host from a build that wires the import bridge (branch feat/zcode-session-import), or upgrade to an official pi.session.importBatch host.", - "importer.source.scanTimeout": "Scan timed out", - "importer.source.scanFailed": "Scan failed", - "importer.count.oversized": "{count} may be truncated", - "importer.truncated": "{count} sessions had content truncated", - "importer.custom.badge": "custom", - "importer.custom.title": "Custom sources", - "importer.custom.hint": "Add your own tool by describing it in {path} (declarative JSON — no code is ever executed).", - "importer.custom.reload": "Reload", - "importer.custom.count": "{count} loaded", - "importer.custom.drivers": "Available formats: {drivers}", - "importer.custom.errors": "Config problems: {errors}", - "importer.custom.reloaded": "Reloaded custom sources", - "importer.custom.reloadFailed": "Reload failed: {error}", - - "forge.title": "Session Forge", - "forge.sub": "Read back the sessions this plugin imported, then distill them into project conventions and reusable practices using the host's quota.", - "forge.reload": "Reload sessions", - "forge.selectAll": "Select all", - "forge.clear": "Clear", - "forge.model.default": "Default model", - "forge.goal.placeholder": "Distillation focus (optional), e.g. only deployment & CI issues", - "forge.distill": "Distill", - "forge.save": "Save to workspace", - "forge.banner": "Just imported {count} sessions via Universal Session Import ({ago}).", - "forge.status.reading": "Reading imported sessions…", - "forge.count.total": "Total {count}", - "forge.status.hasSessions": "Imported {count} sessions — select and distill.", - "forge.status.noSessions": "No imported sessions yet — import some via Universal Session Import first.", - "forge.status.readFailed": "Failed to read: {error}", - "forge.session.untitled": "(untitled)", - "forge.distill.pickOne": "Select at least one session first.", - "forge.distill.working": "Distilling {count} sessions… (rate-limited to 8/min)", - "forge.distill.done": "Done.", - "forge.distill.unreadable": ", {count} sessions could not be read", - "forge.distill.failed": "Distillation failed: {error}", - "forge.distill.rateLimited": " (rate-limited, retry in ~{sec}s)", - "forge.save.written": "Wrote {path} ({bytes} bytes).", - "forge.save.failed": "Save failed: {error}", - "forge.saveMode.label": "Save mode", - "forge.saveMode.overwrite": "Overwrite", - "forge.saveMode.append": "Append", - "forge.saveMode.merge": "Merge (marked section)", - "forge.history.open": "History", - "forge.history.title": "Distillation history", - "forge.history.close": "Close", - "forge.history.empty": "No distillations yet.", - "forge.history.count": "{count} runs", - "forge.caps.missing": "Host is missing capabilities: {list}. Upgrade PI-Desktop or re-authorize.", - "forge.ago.seconds": "{sec}s ago", - "forge.ago.minutes": "{min}m ago", - }, - "zh-CN": { - "app.title": "一体化会话导入", - "importer.heading": "一体化会话导入", - "importer.hint": "扫描本机安装的编程工具,选择来源后勾选会话导入", - "importer.scan": "扫描本机工具", - "importer.filter.placeholder": "搜索标题或项目…", - "importer.selectAll": "全选", - "importer.clearAll": "清除", - "importer.listbox.label": "会话列表", - "importer.previewEmpty": "在左侧选择一个会话进行预览", - "importer.import": "导入为会话", - "importer.count.selected": "已选 {selected} / {total}", - "importer.status.scanning": "正在扫描本机工具…", - "importer.status.scanDone": "扫描完成:点击上方工具选择来源", - "importer.status.loadingList": "正在载入会话列表…", - "importer.status.loadFailed": "载入失败:{error}", - "importer.source.detecting": "检测中…", - "importer.source.notFound": "未检测到", - "importer.count.sessions": "{count} 个会话", - "importer.count.scanningMore": "(仍在扫描…)", - "importer.group.noProject": "未关联项目", - "importer.group.selectAll": "全选该项目", - "importer.group.deselectAll": "取消全选", - "importer.preview.noProject": "未关联项目", - "importer.preview.unknownModel": "未知模型", - "importer.preview.Nmessages": "{count} 条消息", - "importer.preview.updatedAt": "更新于 {date}", - "importer.status.loadedMessages": "已载入 {count} 条消息", - "importer.importing": "正在导入 {count} 个会话…", - "importer.imported": "已导入 {count} 个会话", - "importer.skipped": "跳过 {count} 个重复/不可读", - "importer.failed": "{count} 个失败", - "importer.importDone.timing": "耗时 {total}s(读取 {convert}s · 宿主写入 {host}s)", - "importer.importDone.done": "——已按项目归组;打开对应项目或在「项目」页查看", - "importer.legacy.imported": "已导入 {imported} 个会话(跳过 {skipped} 个重复/不可读)——左侧项目列表即刻可见", - "importer.importFailed": "导入失败:{error}", - "importer.unreadable": "跳过 {count} 个不可读", - "importer.hostHint": "宿主未暴露会话导入接口(session.import / importBatch)。请使用接入了导入桥接的宿主构建(feat/zcode-session-import 分支),或升级到支持官方 pi.session.importBatch 的宿主版本。", - "importer.source.scanTimeout": "扫描超时", - "importer.source.scanFailed": "扫描失败", - "importer.count.oversized": "{count} 个可能截断", - "importer.truncated": "{count} 个会话内容因过大被截断", - "importer.custom.badge": "自定义", - "importer.custom.title": "自定义来源", - "importer.custom.hint": "在 {path} 中用声明式 JSON 描述你的工具即可新增来源(只读取数据,绝不执行代码)。", - "importer.custom.reload": "重新加载", - "importer.custom.count": "已加载 {count} 个", - "importer.custom.drivers": "可用格式:{drivers}", - "importer.custom.errors": "配置问题:{errors}", - "importer.custom.reloaded": "自定义来源已重新加载", - "importer.custom.reloadFailed": "重新加载失败:{error}", - - "forge.title": "会话熔炉", - "forge.sub": "读回本插件导入的会话,用宿主额度蒸馏成项目约定与可复用做法。", - "forge.reload": "刷新会话", - "forge.selectAll": "全选", - "forge.clear": "清空", - "forge.model.default": "默认模型", - "forge.goal.placeholder": "本次提炼重点(可选),例如:只关注部署与 CI 问题", - "forge.distill": "蒸馏", - "forge.save": "保存到工作区", - "forge.banner": "刚刚在「一体化会话导入」导入了 {count} 个会话({ago})。", - "forge.status.reading": "读取已导入会话…", - "forge.count.total": "共 {count} 个", - "forge.status.hasSessions": "已导入 {count} 个会话,勾选后蒸馏。", - "forge.status.noSessions": "还没有导入过会话 —— 先用「一体化会话导入」导入一批。", - "forge.status.readFailed": "读取失败:{error}", - "forge.session.untitled": "(无标题)", - "forge.distill.pickOne": "先勾选至少一个会话。", - "forge.distill.working": "正在蒸馏 {count} 个会话…(限速 8 次/分钟)", - "forge.distill.done": "完成。", - "forge.distill.unreadable": ",{count} 个会话读取失败", - "forge.distill.failed": "蒸馏失败:{error}", - "forge.distill.rateLimited": "(限速,约 {sec} 秒后重试)", - "forge.save.written": "已写入 {path}({bytes} 字节)。", - "forge.save.failed": "保存失败:{error}", - "forge.saveMode.label": "保存方式", - "forge.saveMode.overwrite": "覆盖", - "forge.saveMode.append": "追加", - "forge.saveMode.merge": "合并(带标记)", - "forge.history.open": "蒸馏历史", - "forge.history.title": "蒸馏历史", - "forge.history.close": "关闭", - "forge.history.empty": "暂无蒸馏记录。", - "forge.history.count": "{count} 次", - "forge.caps.missing": "当前宿主缺少能力:{list}。请升级 PI-Desktop 或重新授权权限。", - "forge.ago.seconds": "{sec} 秒前", - "forge.ago.minutes": "{min} 分钟前", - }, - }; - - const DEFAULT_LOCALE = "zh-CN"; - const LOCALES = Object.keys(DICT); - - /** Resolve a key for a locale, falling back to zh-CN then to the key. */ - function resolve(locale, key) { - const table = DICT[locale]; - if (table && Object.prototype.hasOwnProperty.call(table, key)) return table[key]; - const fallback = DICT[DEFAULT_LOCALE]; - if (fallback && Object.prototype.hasOwnProperty.call(fallback, key)) return fallback[key]; - return key; - } - - /** - * Get a localized message. - * @param {string} locale one of LOCALES (anything else falls back to zh-CN) - * @param {string} key dictionary key - * @param {Record} [params] `{name}` interpolation - * @returns {string} - */ - function getMessage(locale, key, params) { - let text = resolve(locale, key); - if (params && typeof text === "string") { - text = text.replace(/\{(\w+)\}/g, (m, p) => - Object.prototype.hasOwnProperty.call(params, p) ? String(params[p]) : m, - ); - } - return text; - } - - /** Best-effort locale from the browser/webview navigator. */ - function detectLocale(nav) { - let navLang = ""; - try { - const n = nav || (typeof navigator !== "undefined" ? navigator : null); - navLang = (n && (n.language || (n.languages && n.languages[0]))) || ""; - } catch { - navLang = ""; - } - return /^zh/i.test(navLang) ? "zh-CN" : "en"; - } - - return { DICT, LOCALES, DEFAULT_LOCALE, getMessage, detectLocale }; -}); diff --git a/plugins/io.github.muzimu217.session-import/lib/registry.js b/plugins/io.github.muzimu217.session-import/lib/registry.js deleted file mode 100644 index 4aa8273..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/registry.js +++ /dev/null @@ -1,68 +0,0 @@ -/** Registry of every supported local source. */ -"use strict"; - -const zcode = require("./sources/zcode"); -const workbuddy = require("./sources/workbuddy"); -const claude = require("./sources/claude"); -const codex = require("./sources/codex"); -const opencode = require("./sources/opencode"); -const pi = require("./sources/pi"); -const { loadCustomSources, CONFIG_PATH } = require("./custom-sources"); -const { makeDeclarativeSource } = require("./sources/declarative"); - -/** - * Built-in sources. ADR 0008 mandates exactly six and - * test/registry.test.mjs asserts that, so this array must stay at 6. - * User-defined sources are kept in `dynamicAdapters` — adding one can never - * violate that invariant or shadow a shipped adapter. - */ -const ADAPTERS = [zcode, workbuddy, claude, codex, opencode, pi]; - -let dynamicAdapters = []; -let lastLoad = { count: 0, errors: [], configPath: null }; - -/** - * (Re)build the user-defined adapters for a workspace. - * Safe to call repeatedly; an absent or broken config simply yields none. - */ -async function refreshDynamicSources(options = {}) { - const { specs, errors, configPath } = await loadCustomSources(options); - dynamicAdapters = []; - for (const spec of specs) { - try { - dynamicAdapters.push(makeDeclarativeSource(spec)); - } catch (err) { - errors.push(`spec ${spec.id}: ${err && err.message}`); - } - } - lastLoad = { count: dynamicAdapters.length, errors, configPath }; - return dynamicAdapters; -} - -function getDynamicAdapters() { - return dynamicAdapters; -} - -/** Diagnostics for the UI (why a custom source did or did not appear). */ -function getDynamicLoadReport() { - return lastLoad; -} - -/** Built-ins first, so a custom spec can never shadow a shipped source. */ -function allAdapters() { - return dynamicAdapters.length ? [...ADAPTERS, ...dynamicAdapters] : ADAPTERS; -} - -function getAdapter(source) { - return allAdapters().find((a) => a.source === source) ?? null; -} - -module.exports = { - ADAPTERS, - getAdapter, - allAdapters, - getDynamicAdapters, - getDynamicLoadReport, - refreshDynamicSources, - CONFIG_PATH, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/save-modes.js b/plugins/io.github.muzimu217.session-import/lib/save-modes.js deleted file mode 100644 index 90068eb..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/save-modes.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * lib/save-modes.js — combiners for the three distillation save modes. - * - * Pure (no host dependency) so it is fully unit-testable. - * - overwrite: replace the target file entirely - * - append: add the new distilled text after a blank line - * - merge: keep a fenced section delimited by markers; replace that - * section on repeat runs, or append it on first run. - */ -"use strict"; - -const MERGE_START = ""; -const MERGE_END = ""; - -const MODES = ["overwrite", "append", "merge"]; - -function isMode(value) { - return MODES.includes(value); -} - -function normalize(mode) { - return isMode(mode) ? mode : "overwrite"; -} - -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** Wrap incoming distilled text in merge markers. */ -function wrapSection(incoming) { - return `${MERGE_START}\n${incoming}\n${MERGE_END}`; -} - -/** - * Combine existing file content with newly distilled content. - * @param {string} existing current file content (may be "") - * @param {string} incoming newly distilled content - * @param {string} mode one of MODES (anything else → overwrite) - * @returns {string} - */ -function combine(existing, incoming, mode) { - const m = normalize(mode); - if (m === "overwrite") return incoming; - if (m === "append") { - if (!existing) return incoming; - return `${existing}\n\n${incoming}`; - } - // merge - if (!existing) return wrapSection(incoming); - const re = new RegExp(`${escapeRegex(MERGE_START)}[\\s\\S]*?${escapeRegex(MERGE_END)}`); - if (re.test(existing)) return existing.replace(re, wrapSection(incoming)); - return `${existing}\n\n${wrapSection(incoming)}`; -} - -/** True when existing content already carries a merge section. */ -function hasMergeSection(existing) { - return typeof existing === "string" && existing.includes(MERGE_START); -} - -module.exports = { - MODES, - MERGE_START, - MERGE_END, - isMode, - normalize, - wrapSection, - combine, - hasMergeSection, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/claude.js b/plugins/io.github.muzimu217.session-import/lib/sources/claude.js deleted file mode 100644 index 8be9bf3..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/claude.js +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Claude Code adapter: reads ~/.claude/projects//.jsonl. - * Ported from the built-in claude.ts importer. - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const fsp = require("node:fs").promises; -const { createReadStream } = require("node:fs"); -const readline = require("node:readline"); -const { toIso, truncateTitle, projectNameOf } = require("../util"); - -const SOURCE = "claude-code"; - -// While scanning, once we have title + project + model, a transcript's head is -// enough: stop after this many conversation lines so multi-MB session files -// cost a fraction of a full parse. -const SCAN_HEAD_LINES = 40; - -const projectsDirFor = (home = os.homedir()) => path.join(home, ".claude", "projects"); - -async function readLines(filePath) { - const raw = await fsp.readFile(filePath, "utf8"); - const out = []; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed)); - } catch { - // skip malformed lines - } - } - return out; -} - -/** - * Streaming variant: Claude Code transcripts routinely reach tens of MB, and - * readFile + split + JSON.parse over the whole blob blocks the plugin host - * (measured ~40ms/MB of pure parsing). Streaming keeps memory flat and lets - * the caller stop early as soon as a predicate has seen enough. - * - * `shouldStop(counter)` is consulted per line; pass `null` to read it all. - */ -async function streamLines(filePath, onLine, shouldStop) { - const stream = createReadStream(filePath, { encoding: "utf8" }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const counter = { lines: 0, conversation: 0 }; - try { - for await (const line of rl) { - counter.lines += 1; - const trimmed = line.trim(); - if (!trimmed) continue; - let parsed; - try { - parsed = JSON.parse(trimmed); - } catch { - continue; // malformed line — skip - } - onLine(parsed, counter); - if (shouldStop && shouldStop(counter)) break; - } - } finally { - rl.close(); - stream.destroy(); - } - return counter; -} - -function isConversationLine(line) { - return ( - (line.type === "user" || line.type === "assistant") && - line.isSidechain !== true && - !!line.message - ); -} - -function blockText(content) { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter((b) => b.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("\n") - .trim(); -} - -// Claude Code injects synthetic user lines (command caveats, system reminders, -// slash-command transcripts) that all start with an XML-ish tag. -function isSyntheticUserText(text) { - return text.startsWith("<"); -} - -async function scan() { - const projectsDir = projectsDirFor(); - let projectDirs = []; - try { - projectDirs = await fsp.readdir(projectsDir); - } catch { - return []; - } - const perDir = await Promise.all( - projectDirs.map(async (dir) => { - const dirPath = path.join(projectsDir, dir); - let files = []; - try { - files = (await fsp.readdir(dirPath)).filter((f) => f.endsWith(".jsonl")); - } catch { - return []; - } - const found = await Promise.all( - files.map(async (file) => { - const filePath = path.join(dirPath, file); - // Everything the list row needs is in the first few conversation - // lines (first user prompt, cwd, model). The only field that needs - // the file tail is the last timestamp, which we track as we stream - // and abort on a cheap heuristic: the head of a transcript is tiny - // compared to its body, so we stop early once we have the metadata - // AND enough lines to trust the ordering. - let firstUser = null; - let firstLine = null; - let lastTimestamp = null; - let messageCount = 0; - let model = null; - try { - await streamLines( - filePath, - (line) => { - if (!isConversationLine(line)) return; - messageCount += 1; - if (!firstLine) firstLine = line; - if (line.timestamp) lastTimestamp = line.timestamp; - if (!model && line.type === "assistant" && line.message?.model) { - model = line.message.model; - } - if (!firstUser && line.type === "user") { - const text = blockText(line.message?.content); - if (text && !isSyntheticUserText(text)) firstUser = text; - } - }, - (counter) => - // Metadata complete: title + project + model known. The tail - // timestamp keeps updating below, so stop soon after. - !!firstUser && - !!model && - counter.conversation >= SCAN_HEAD_LINES, - ); - } catch { - return null; // unreadable session file — skip - } - if (messageCount === 0 || !firstLine) return null; - return { - source: SOURCE, - externalId: path.basename(file, ".jsonl"), - title: truncateTitle(firstUser || "") || path.basename(file, ".jsonl"), - fullTitle: firstUser || "", - projectName: projectNameOf(firstLine?.cwd) ?? dir, - projectPath: firstLine?.cwd ?? null, - modelId: model, - providerId: null, - createdAt: toIso(firstLine?.timestamp), - updatedAt: toIso(lastTimestamp), - messageCount, - filePath, - }; - }), - ); - return found.filter(Boolean); - }), - ); - const sessions = perDir.flat(); - sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); - return sessions; -} - -async function convert(summary) { - const lines = (await readLines(summary.filePath)).filter(isConversationLine); - const messages = []; - const pendingTools = new Map(); - - for (const line of lines) { - const createdAt = toIso(line.timestamp); - const content = line.message?.content; - const blocks = Array.isArray(content) ? content : null; - - if (line.type === "assistant") { - const text = blockText(content); - if (text) messages.push({ role: "assistant", content: text, createdAt }); - for (const b of blocks ?? []) { - if (b.type === "tool_use" && b.id) { - pendingTools.set(b.id, { name: b.name, args: b.input, createdAt }); - } - } - } else if (line.type === "user") { - const toolResults = (blocks ?? []).filter((b) => b.type === "tool_result"); - if (toolResults.length > 0) { - for (const b of toolResults) { - const pending = pendingTools.get(b.tool_use_id); - pendingTools.delete(b.tool_use_id); - const resultText = - typeof b.content === "string" ? b.content : blockText(b.content); - messages.push({ - role: "tool", - content: resultText, - createdAt, - toolName: pending?.name ?? "tool", - toolCallId: b.tool_use_id, - toolStatus: b.is_error ? "error" : "success", - toolArgs: pending?.args, - toolResult: resultText, - }); - } - } else { - const text = blockText(content); - if (text && !isSyntheticUserText(text)) { - messages.push({ role: "user", content: text, createdAt }); - } - } - } - } - - return { - session: { - id: `import-claude-code-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath, - modelId: summary.modelId, - providerId: null, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; -} - -module.exports = { source: SOURCE, label: "Claude Code", projectsDirFor, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/codex.js b/plugins/io.github.muzimu217.session-import/lib/sources/codex.js deleted file mode 100644 index bd3f3c2..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/codex.js +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Codex adapter: reads the JSONL session files under - * ~/.codex/sessions (both the newer - * {timestamp,type,payload} wraps and the older bare header format). - * Ported from the built-in codex.ts importer. - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const fsp = require("node:fs").promises; -const { createReadStream } = require("node:fs"); -const readline = require("node:readline"); -const { toIso, truncateTitle, projectNameOf, mapValuesWithConcurrency } = require("../util"); - -const SOURCE = "codex"; - -const sessionsDirFor = (home = os.homedir()) => path.join(home, ".codex", "sessions"); - -// How many rollout files to stream at once. High enough to hide read latency, -// low enough to keep the file-descriptor count sane. -const SCAN_CONCURRENCY = 16; - -// Progressive scan budget. `scanFast` returns inside this window, then the -// adapter is asked for the full list in the background. Tuned so a cold panel -// open is interactive on trees that hold hundreds of multi-MB rollouts. -const SCAN_FAST_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; // 14 days -const SCAN_FAST_MAX_FILES = 120; -const SCAN_FAST_MAX_BYTES = 96 * 1024 * 1024; - -// Codex keeps every rollout verbatim, so a single project can hold hundreds of -// multi-MB `.jsonl` files (measured: 864 files / 2.6 GB on a working machine). -// Scanning them all costs ~16s. For the list row we only need the session -// metadata plus the first real user prompt, all of which live near the top, so -// stop reading once we have them and a little confirmation. -const SCAN_HEAD_LINES = 40; - -function isKeptItemType(type) { - return ( - type === "message" || - type === "function_call" || - type === "function_call_output" || - type === "response_item" - ); -} - -/** - * Streaming line reader. `shouldStop(counter)` is consulted after every parsed - * line so callers can abort as soon as they have enough (keeps peak memory at - * one line instead of a whole multi-MB transcript). - */ -async function streamLines(filePath, onLine, shouldStop) { - const stream = createReadStream(filePath, { encoding: "utf8" }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - const counter = { lines: 0, items: 0 }; - try { - for await (const line of rl) { - counter.lines += 1; - const trimmed = line.trim(); - if (!trimmed) continue; - let parsed; - try { - parsed = JSON.parse(trimmed); - } catch { - continue; // malformed line — skip - } - onLine(parsed, counter); - if (shouldStop && shouldStop(counter)) break; - } - } finally { - rl.close(); - stream.destroy(); - } - return counter; -} - -function itemText(item) { - if (!Array.isArray(item.content)) return ""; - return item.content - .filter( - (c) => - (c.type === "input_text" || c.type === "output_text" || c.type === "text") && - typeof c.text === "string", - ) - .map((c) => c.text) - .join("\n") - .trim(); -} - -// Codex prepends synthetic user messages carrying repo instructions/env info. -function isSyntheticUserText(text) { - return ( - text.startsWith("<") || - text.startsWith("# AGENTS.md") || - text.startsWith("You are Codex") - ); -} - -async function parseFile(filePath) { - let raw; - try { - raw = await fsp.readFile(filePath, "utf8"); - } catch { - return null; - } - const parsed = { externalId: "", cwd: null, startedAt: null, lastAt: null, items: [] }; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - let obj; - try { - obj = JSON.parse(trimmed); - } catch { - continue; - } - // Newer format wraps everything in {timestamp, type, payload}. - if (obj.type === "session_meta" && obj.payload) { - parsed.externalId = obj.payload.id ?? parsed.externalId; - parsed.cwd = obj.payload.cwd ?? parsed.cwd; - parsed.startedAt = obj.payload.timestamp ?? obj.timestamp ?? parsed.startedAt; - continue; - } - if (obj.type === "response_item" && obj.payload) { - parsed.items.push({ item: obj.payload, timestamp: obj.timestamp ?? null }); - if (obj.timestamp) parsed.lastAt = obj.timestamp; - continue; - } - // Older format: first line is a bare session header, items are bare lines. - if (!parsed.externalId && obj.id && obj.timestamp && !obj.type) { - parsed.externalId = obj.id; - parsed.startedAt = obj.timestamp; - parsed.cwd = obj.cwd ?? null; - continue; - } - if ( - obj.type === "message" || - obj.type === "function_call" || - obj.type === "function_call_output" - ) { - parsed.items.push({ item: obj, timestamp: obj.timestamp ?? null }); - if (obj.timestamp) parsed.lastAt = obj.timestamp; - } - } - if (!parsed.externalId) parsed.externalId = path.basename(filePath, ".jsonl"); - return parsed.items.length > 0 ? parsed : null; -} - -/** - * Walk the sessions tree and stat every rollout. - * - * `stat` is essentially free on a warm cache (~6ms for 800+ files) whereas - * *opening* each file costs ~5ms of syscall latency, so we gather size+mtime up - * front. That lets the progressive scan below decide which files are worth - * opening without paying for a single `open()` first. - */ -async function listSessionEntries() { - const paths = []; - const walk = async (dir, depth) => { - let entries = []; - try { - entries = await fsp.readdir(dir); - } catch { - return; - } - for (const entry of entries) { - const full = path.join(dir, entry); - if (entry.endsWith(".jsonl")) paths.push(full); - else if (depth < 3) await walk(full, depth + 1); - } - }; - await walk(sessionsDirFor(), 0); - const stated = await mapValuesWithConcurrency(paths, SCAN_CONCURRENCY, async (filePath) => { - try { - const info = await fsp.stat(filePath); - if (!info.isFile() || info.size === 0) return null; - return { filePath, size: info.size, mtimeMs: info.mtimeMs }; - } catch { - return null; - } - }); - return stated.filter(Boolean); -} - -/** Pick the files whose contents we can afford to open right now. */ -function selectFiles(entries, { maxFiles, maxBytes, sinceMs }) { - let candidates = entries; - if (sinceMs) { - const recent = entries.filter((e) => e.mtimeMs >= sinceMs); - // Never let a stale clock shrink the list to nothing. - if (recent.length > 0) candidates = recent; - } - if (typeof maxFiles === "number" && candidates.length > maxFiles) { - // Newest first: those are the sessions a user is actually looking for. - candidates = [...candidates].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, maxFiles); - } - if (typeof maxBytes === "number") { - const kept = []; - let bytes = 0; - for (const entry of candidates) { - if (bytes + entry.size > maxBytes && kept.length > 0) continue; - bytes += entry.size; - kept.push(entry); - } - return kept; - } - return candidates; -} - -/** - * Streaming scan: parse only as much of each rollout as the list row needs. - * - * The previous implementation called `parseFile()` for every file, which read - * the full blob and JSON-parsed every line — 2.6 GB / 583k lines on a real - * ~/.codex/sessions tree, ~16s of blocking work before the panel could render. - * Here each file is streamed and aborted once we have the session id, cwd, - * the first real user prompt and a model-ish signal. - * - * Counts (`messageCount`, `updatedAt`) become head-limited approximations: the - * list only renders a size hint and a relative time, and `convert()` re-reads - * the file in full at import time, so nothing user-visible is lost. - */ -async function scanFileMetadata(filePath) { - let externalId = ""; - let cwd = null; - let startedAt = null; - let lastAt = null; - let firstUser = null; - let messageCount = 0; - let sawAnyItem = false; - - const stop = (counter) => - !!firstUser && counter.items >= SCAN_HEAD_LINES; - - try { - await streamLines( - filePath, - (obj, counter) => { - // Newer format: {timestamp, type, payload}. - if (obj.type === "session_meta" && obj.payload) { - externalId = obj.payload.id ?? externalId; - cwd = obj.payload.cwd ?? cwd; - startedAt = obj.payload.timestamp ?? obj.timestamp ?? startedAt; - return; - } - if (obj.type === "response_item" && obj.payload) { - sawAnyItem = true; - counter.items += 1; - if (obj.timestamp) lastAt = obj.timestamp; - const item = obj.payload; - if (!firstUser && item.type === "message" && item.role === "user") { - const text = itemText(item); - if (text && !isSyntheticUserText(text)) firstUser = text; - } - return; - } - // Older format: bare session header, then bare item lines. - if (!externalId && obj.id && obj.timestamp && !obj.type) { - externalId = obj.id; - startedAt = obj.timestamp; - cwd = obj.cwd ?? null; - return; - } - if (isKeptItemType(obj.type)) { - sawAnyItem = true; - counter.items += 1; - if (obj.timestamp) lastAt = obj.timestamp; - if (!firstUser && obj.type === "message" && obj.role === "user") { - const text = itemText(obj); - if (text && !isSyntheticUserText(text)) firstUser = text; - } - } - }, - stop, - ); - } catch { - return null; // unreadable session file — skip - } - - if (!externalId) externalId = path.basename(filePath, ".jsonl"); - if (!sawAnyItem) return null; - return { externalId, cwd, startedAt, lastAt, firstUser, messageCount }; -} - -/** - * First pass: the sessions a user most likely wants — recent, and bounded in - * both file count and total bytes so the panel can paint a list in well under a - * second even on a 3 GB rollout tree. - */ -async function scanFast(entries) { - const picked = selectFiles(entries ?? (await listSessionEntries()), { - maxFiles: SCAN_FAST_MAX_FILES, - maxBytes: SCAN_FAST_MAX_BYTES, - sinceMs: Date.now() - SCAN_FAST_WINDOW_MS, - }); - return mapSessions(picked); -} - -/** Full pass: every rollout under the sessions tree. */ -async function scan() { - const entries = await listSessionEntries(); - return mapSessions(entries); -} - -async function mapSessions(entries) { - // Transcribing hundreds of rollouts is I/O bound: fan out so the scan - // finishes in parallel rather than one file at a time. - const results = await mapValuesWithConcurrency( - entries, - SCAN_CONCURRENCY, - async (entry) => { - const filePath = entry.filePath ?? entry; - const meta = await scanFileMetadata(filePath); - if (!meta || !meta.firstUser) return null; - return { - source: SOURCE, - externalId: meta.externalId, - title: truncateTitle(meta.firstUser) || meta.externalId, - fullTitle: meta.firstUser, - projectName: projectNameOf(meta.cwd) ?? "Codex", - projectPath: meta.cwd, - modelId: null, - providerId: null, - createdAt: toIso(meta.startedAt), - updatedAt: toIso(meta.lastAt, toIso(meta.startedAt)), - messageCount: meta.messageCount, - filePath, - }; - }, - ); - const sessions = results.filter(Boolean); - sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); - return sessions; -} - -async function convert(summary) { - const parsed = await parseFile(summary.filePath); - const messages = []; - const pendingCalls = new Map(); - - for (const { item, timestamp } of parsed?.items ?? []) { - const createdAt = toIso(timestamp, summary.createdAt); - if (item.type === "message") { - const text = itemText(item); - if (!text || (item.role === "user" && isSyntheticUserText(text))) continue; - messages.push({ - role: item.role === "user" ? "user" : "assistant", - content: text, - createdAt, - }); - } else if (item.type === "function_call" && item.call_id) { - let args; - try { - args = JSON.parse(item.arguments ?? ""); - } catch { - args = item.arguments; - } - pendingCalls.set(item.call_id, { name: item.name ?? "tool", args }); - } else if (item.type === "function_call_output" && item.call_id) { - const pending = pendingCalls.get(item.call_id); - pendingCalls.delete(item.call_id); - const output = - typeof item.output === "string" ? item.output : JSON.stringify(item.output); - messages.push({ - role: "tool", - content: output, - createdAt, - toolName: pending?.name ?? "tool", - toolCallId: item.call_id, - toolStatus: "success", - toolArgs: pending?.args, - toolResult: output, - }); - } - } - - return { - session: { - id: `import-codex-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath, - modelId: summary.modelId, - providerId: null, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; -} - -module.exports = { source: SOURCE, label: "Codex", sessionsDirFor, scan, scanFast, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/declarative.js b/plugins/io.github.muzimu217.session-import/lib/sources/declarative.js deleted file mode 100644 index d683ea2..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/declarative.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Turns a declarative spec into an adapter with exactly the same contract as - * the hand-written sources in lib/sources/*.js: - * { source, label, scan(), convert(summary) } - * - * scan() -> summaries[] (empty when the app's data is absent) - * convert(summary) -> { session, messages } - */ -"use strict"; - -const { getDriver } = require("../drivers"); - -/** - * @param {object} spec validated spec (see lib/custom-sources.js) - * @returns {{source:string,label:string,custom:true,driver:string,dataPath:string,scan:Function,convert:Function}} - */ -function makeDeclarativeSource(spec) { - const driver = getDriver(spec.driver); - if (!driver) { - throw new Error(`unknown driver "${spec.driver}"`); - } - const sourceId = spec.id; - - return { - source: sourceId, - label: spec.label || sourceId, - custom: true, - driver: spec.driver, - dataPath: spec.root || spec.db || "", - - async scan() { - try { - const summaries = await driver.scan(spec, sourceId); - return Array.isArray(summaries) ? summaries : []; - } catch { - // A broken spec must never break the whole panel. - return []; - } - }, - - async convert(summary) { - try { - const out = await driver.convert(spec, summary); - return out && out.session ? out : { session: null, messages: [] }; - } catch { - return { session: null, messages: [] }; - } - }, - }; -} - -module.exports = { makeDeclarativeSource }; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/opencode.js b/plugins/io.github.muzimu217.session-import/lib/sources/opencode.js deleted file mode 100644 index 352300e..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/opencode.js +++ /dev/null @@ -1,229 +0,0 @@ -/** - * OpenCode adapter: reads OpenCode v1.x's single SQLite database. - * - * OpenCode moved off the old per-file JSON tree (storage/session|message|part) - * to one SQLite database. The on-disk schema is the same three-layer model as - * ZCode — session -> message -> part, with each row's payload in a `data` - * JSON column — so this adapter mirrors lib/sources/zcode.js but queries the - * DB instead of walking directories. - * - * The database lives in different places per platform/install (#682): - * the Windows app keeps it under %LOCALAPPDATA%\opencode\data\, everything - * else (macOS, Linux, and Windows CLI installs that honour the Unix-style - * layout) under the XDG data dir (~/.local/share/opencode by default). - * Candidates are probed in that order; the first one that exists wins. - * - * Read-only via the built-in node:sqlite; never mutates the user's OpenCode data. - */ -"use strict"; - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { toIso, truncateTitle, projectNameOf } = require("../util"); - -const SOURCE = "opencode"; - -function dbPathCandidates({ home = os.homedir(), env = process.env } = {}) { - const out = []; - // Windows app storage (confirmed by OpenCode's own tracker): %LOCALAPPDATA%\opencode\data. - if (env.LOCALAPPDATA) { - out.push(path.join(env.LOCALAPPDATA, "opencode", "data", "opencode.db")); - } - // XDG data dir first when set, then the ~/.local/share default — probing - // both costs nothing and covers installs that ignore XDG. - if (env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)) { - out.push(path.join(env.XDG_DATA_HOME, "opencode", "opencode.db")); - } - out.push(path.join(home, ".local", "share", "opencode", "opencode.db")); - return out; -} - -function resolveDbPath(options) { - for (const candidate of dbPathCandidates(options)) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { - // Unreadable stat (permissions, exotic mounts): fall through to the next candidate. - } - } - return null; -} - -/** First candidate (legacy single-path shape); kept for compatibility. */ -function dbPathFor(options) { - return dbPathCandidates(options)[0]; -} - -function parseJsonColumn(raw) { - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -function mapToolStatus(status) { - if (status === "error") return "error"; - if (status === "completed") return "success"; - return "running"; -} - -function openDb(dbPath) { - const { DatabaseSync } = require("node:sqlite"); - return new DatabaseSync(dbPath, { readOnly: true }); -} - -async function scan() { - const dbPath = resolveDbPath(); - if (!dbPath) { - // DB absent (OpenCode never run / not installed) — nothing to import. - return []; - } - let db; - try { - db = openDb(dbPath); - } catch { - // Present but unreadable (locked, corrupt, unsupported): report nothing - // rather than half a source. - return []; - } - try { - const rows = db - .prepare( - `SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, - COALESCE(m.message_count, 0) AS message_count - FROM session s - LEFT JOIN ( - SELECT session_id, COUNT(*) AS message_count - FROM message - GROUP BY session_id - ) m ON m.session_id = s.id - WHERE m.message_count > 0`, - ) - .all(); - const sessions = []; - for (const row of rows) { - if (!row.id) continue; - sessions.push({ - source: SOURCE, - externalId: row.id, - title: truncateTitle(row.title) || row.id, - fullTitle: String(row.title ?? ""), - projectName: projectNameOf(row.directory) ?? "OpenCode", - projectPath: row.directory ?? null, - modelId: null, - providerId: null, - createdAt: toIso(row.time_created), - updatedAt: toIso(row.time_updated, toIso(row.time_created)), - messageCount: Number(row.message_count), - filePath: dbPath, - }); - } - return sessions; - } finally { - db.close(); - } -} - -async function convert(summary) { - const db = openDb(summary.filePath || resolveDbPath() || dbPathFor()); - try { - const messageRows = db - .prepare( - `SELECT id, time_created, data FROM message - WHERE session_id = ? - ORDER BY time_created, id`, - ) - .all(summary.externalId); - const partRows = db - .prepare( - `SELECT message_id, data FROM part - WHERE session_id = ? - ORDER BY time_created, id`, - ) - .all(summary.externalId); - - const partsByMessage = new Map(); - for (const row of partRows) { - const part = parseJsonColumn(row.data); - if (!part?.type || !row.message_id) continue; - const bucket = partsByMessage.get(row.message_id) ?? []; - bucket.push(part); - partsByMessage.set(row.message_id, bucket); - } - - const messages = []; - let modelId = null; - let providerId = null; - - const flushText = (role, createdAt, texts) => { - const text = texts.join("\n").trim(); - texts.length = 0; - if (!text) return; - messages.push({ role, content: text, createdAt }); - }; - - for (const row of messageRows) { - const msg = parseJsonColumn(row.data); - if (!msg || !row.id) continue; - if (msg.role !== "user" && msg.role !== "assistant") continue; - const role = msg.role; - const createdAt = toIso(msg.time?.created ?? row.time_created); - if (role === "assistant") { - modelId = msg.modelID ?? modelId; - providerId = msg.providerID ?? providerId; - } - - // Emit parts in stored order so tool calls keep their place. - const texts = []; - for (const part of partsByMessage.get(row.id) ?? []) { - if (part.type === "text" && part.text && part.synthetic !== true) { - texts.push(part.text); - } else if (part.type === "tool") { - flushText(role, createdAt, texts); - const output = part.state?.output; - const outputText = - typeof output === "string" ? output : output ? JSON.stringify(output) : ""; - messages.push({ - role: "tool", - content: outputText, - toolName: part.tool ?? "tool", - toolStatus: mapToolStatus(part.state?.status), - toolArgs: part.state?.input ?? null, - toolResult: outputText, - createdAt, - }); - } - } - flushText(role, createdAt, texts); - } - - return { - session: { - id: `import-opencode-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath, - modelId, - providerId, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; - } finally { - db.close(); - } -} - -module.exports = { - source: SOURCE, - label: "OpenCode", - dbPathFor, - dbPathCandidates, - resolveDbPath, - scan, - convert, -}; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/pi.js b/plugins/io.github.muzimu217.session-import/lib/sources/pi.js deleted file mode 100644 index 130cf68..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/pi.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Pi adapter: reads ~/.pi/agent/sessions/**\/.jsonl - * (first line = session header, remaining lines = entries). - * Ported from the built-in pi.ts importer. - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const fsp = require("node:fs").promises; -const { toIso, truncateTitle, projectNameOf } = require("../util"); - -const SOURCE = "pi"; - -const sessionsDirFor = (home = os.homedir()) => path.join(home, ".pi", "agent", "sessions"); - -function contentText(content) { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter((b) => b.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("\n") - .trim(); -} - -async function parseFile(filePath) { - let raw; - try { - raw = await fsp.readFile(filePath, "utf8"); - } catch { - return null; - } - const lines = raw.split("\n").filter((l) => l.trim()); - if (lines.length === 0) return null; - let header; - try { - header = JSON.parse(lines[0]); - } catch { - return null; - } - if (header.type !== "session" || !header.id) return null; - const entries = []; - for (const line of lines.slice(1)) { - try { - entries.push(JSON.parse(line)); - } catch { - // skip malformed lines - } - } - return { header, entries }; -} - -async function scan() { - const sessionsDir = sessionsDirFor(); - let dirs = []; - try { - dirs = await fsp.readdir(sessionsDir); - } catch { - return []; - } - const sessions = []; - for (const dir of dirs) { - const dirPath = path.join(sessionsDir, dir); - let files = []; - try { - files = (await fsp.readdir(dirPath)).filter((f) => f.endsWith(".jsonl")); - } catch { - continue; - } - for (const file of files) { - const filePath = path.join(dirPath, file); - const parsed = await parseFile(filePath); - if (!parsed) continue; - const messageEntries = parsed.entries.filter((e) => e.type === "message"); - if (messageEntries.length === 0) continue; - const sessionName = parsed.entries - .filter((e) => e.type === "session_info" && e.name) - .map((e) => e.name) - .pop(); - const firstUser = messageEntries.find( - (e) => e.message?.role === "user" && contentText(e.message.content), - ); - const lastTs = - messageEntries[messageEntries.length - 1]?.timestamp ?? parsed.header.timestamp; - sessions.push({ - source: SOURCE, - externalId: parsed.header.id, - title: - truncateTitle(sessionName ?? contentText(firstUser?.message?.content) ?? "") || - parsed.header.id, - fullTitle: String(sessionName ?? contentText(firstUser?.message?.content) ?? ""), - projectName: projectNameOf(parsed.header.cwd) ?? "Pi", - projectPath: parsed.header.cwd ?? null, - modelId: - messageEntries.find((e) => e.message?.role === "assistant")?.message?.model ?? - null, - providerId: - messageEntries.find((e) => e.message?.role === "assistant")?.message?.provider ?? - null, - createdAt: toIso(parsed.header.timestamp), - updatedAt: toIso(lastTs, toIso(parsed.header.timestamp)), - messageCount: messageEntries.length, - filePath, - }); - } - } - sessions.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); - return sessions; -} - -async function convert(summary) { - const parsed = await parseFile(summary.filePath); - const messages = []; - const pendingCalls = new Map(); - let providerId = null; - let modelId = null; - - for (const entry of parsed?.entries ?? []) { - if (entry.type !== "message" || !entry.message) continue; - const msg = entry.message; - const createdAt = toIso(msg.timestamp ?? entry.timestamp, summary.createdAt); - - if (msg.role === "user") { - const text = contentText(msg.content); - if (text) messages.push({ role: "user", content: text, createdAt }); - } else if (msg.role === "assistant") { - providerId = msg.provider ?? providerId; - modelId = msg.model ?? modelId; - const text = contentText(msg.content); - if (text) messages.push({ role: "assistant", content: text, createdAt }); - for (const b of Array.isArray(msg.content) ? msg.content : []) { - if (b.type === "toolCall" && b.id) { - pendingCalls.set(b.id, { name: b.name, args: b.arguments }); - } - } - } else if (msg.role === "toolResult" && msg.toolCallId) { - const pending = pendingCalls.get(msg.toolCallId); - pendingCalls.delete(msg.toolCallId); - const resultText = contentText(msg.content); - messages.push({ - role: "tool", - content: resultText, - createdAt, - toolName: msg.toolName ?? pending?.name ?? "tool", - toolCallId: msg.toolCallId, - toolStatus: msg.isError ? "error" : "success", - toolArgs: pending?.args, - toolResult: resultText, - }); - } - } - - return { - session: { - id: `import-pi-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath, - modelId, - providerId, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; -} - -module.exports = { source: SOURCE, label: "Pi", sessionsDirFor, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/workbuddy.js b/plugins/io.github.muzimu217.session-import/lib/sources/workbuddy.js deleted file mode 100644 index c5d0302..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/workbuddy.js +++ /dev/null @@ -1,305 +0,0 @@ -/** - * WorkBuddy adapter: reads ~/.workbuddy/projects//.jsonl. - * Logic ported verbatim from the verified feat/workbuddy-session-import - * importer (injection stripping, call pairing, externalized result read-back, - * ai-title preference). - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const fsp = require("node:fs").promises; -const { createReadStream } = require("node:fs"); -const readline = require("node:readline"); -const { toIso, truncateTitle, projectNameOf, mapValuesWithConcurrency } = require("../util"); - -const SOURCE = "workbuddy"; - -// Reading every project transcript for a list row is I/O bound; fan out so the -// panel does not wait on one file at a time. -const SCAN_CONCURRENCY = 12; - -const projectsDirFor = (home = os.homedir()) => - path.join(home, ".workbuddy", "projects"); - -const TEXT_BLOCKS = new Set(["text", "input_text", "output_text"]); - -const INJECTED_BLOCK = - /<(system-reminder|cb_summary|conversation_history_summary)\b[\s\S]*?<\/\1>/gi; -const UNCLOSED_BLOCK = - /<(system-reminder|cb_summary|conversation_history_summary)\b[\s\S]*$/i; -const USER_QUERY = /([\s\S]*?)<\/user_query>/i; -const PERSISTED_OUTPUT = /[\s\S]*?Full output saved to:\s*(\S+)/i; - -async function readLines(filePath) { - const raw = await fsp.readFile(filePath, "utf8"); - const out = []; - for (const line of raw.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - out.push(JSON.parse(trimmed)); - } catch { - // skip malformed lines - } - } - return out; -} - -function blockText(content) { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content - .filter((b) => TEXT_BLOCKS.has(b.type ?? "") && typeof b.text === "string") - .map((b) => b.text) - .join("\n") - .trim(); -} - -function stripInjected(text) { - const paired = text.replace(INJECTED_BLOCK, ""); - const query = paired.match(USER_QUERY); - if (query) return query[1].trim(); - return paired.replace(UNCLOSED_BLOCK, "").trim(); -} - -function resultText(output) { - if (!output) return ""; - if (Array.isArray(output)) return blockText(output); - if (typeof output.text === "string") return output.text.trim(); - return ""; -} - -async function resolveResultText(output, projectsDir) { - const text = resultText(output); - const match = text.match(PERSISTED_OUTPUT); - if (!match) return text; - const external = match[1]; - if (!path.resolve(external).startsWith(path.resolve(projectsDir) + path.sep)) { - return text; - } - try { - const full = await fsp.readFile(external, "utf8"); - return full.trim() || text; - } catch { - return text; - } -} - -function parseArgs(raw) { - if (!raw) return undefined; - try { - return JSON.parse(raw); - } catch { - return raw; - } -} - -function isConversationLine(line) { - return line.type === "message" && (line.role === "user" || line.role === "assistant"); -} - -async function convertFile(filePath, projectsDir) { - const lines = await readLines(filePath); - const messages = []; - const pendingTools = new Map(); - - for (const line of lines) { - if (line.type === "function_call" && line.callId) { - pendingTools.set(line.callId, { - name: line.name ?? "tool", - args: parseArgs(line.arguments), - createdAt: toIso(line.timestamp), - }); - continue; - } - if (line.type === "function_call_result" && line.callId) { - const pending = pendingTools.get(line.callId); - pendingTools.delete(line.callId); - const createdAt = toIso(line.timestamp); - const text = await resolveResultText(line.output, projectsDir); - const failed = line.status === "error" || line.status === "failed"; - messages.push({ - role: "tool", - content: text, - createdAt, - toolName: line.name ?? pending?.name ?? "tool", - toolCallId: line.callId, - toolStatus: failed ? "error" : "success", - toolArgs: pending?.args, - toolResult: text, - }); - continue; - } - if (!isConversationLine(line)) continue; - const createdAt = toIso(line.timestamp); - if (line.role === "assistant") { - const text = blockText(line.content); - if (text) messages.push({ role: "assistant", content: text, createdAt }); - } else { - const text = stripInjected(blockText(line.content)); - if (text) messages.push({ role: "user", content: text, createdAt }); - } - } - return { lines, messages }; -} - -function summarize(filePath, lines, messages) { - const convo = lines.filter(isConversationLine); - const aiTitle = lines - .filter((l) => l.type === "ai-title" && l.aiTitle) - .map((l) => l.aiTitle) - .pop(); - const firstUser = convo.find((l) => { - if (l.role !== "user") return false; - return !!stripInjected(blockText(l.content)); - }); - const cwd = lines.find((l) => l.cwd)?.cwd ?? null; - const externalId = path.basename(filePath, ".jsonl"); - return { - source: SOURCE, - externalId, - title: - truncateTitle(aiTitle ?? "") || - truncateTitle(stripInjected(blockText(firstUser?.content)) || "") || - externalId, - fullTitle: String(aiTitle ?? stripInjected(blockText(firstUser?.content)) ?? ""), - projectName: projectNameOf(cwd) ?? "WorkBuddy", - projectPath: cwd, - modelId: - convo.find((l) => l.role === "assistant" && l.providerData?.model)?.providerData - ?.model ?? null, - providerId: null, - createdAt: toIso(lines[0]?.timestamp), - updatedAt: toIso(lines[lines.length - 1]?.timestamp), - messageCount: convo.length, - filePath, - }; -} - -/** - * Streaming variant of {@link summarize}: parse the transcript line by line - * instead of materialising the whole file. - * - * WorkBuddy's list row needs the *last* line (`updatedAt`) and a trailing - * `ai-title` record, so this cannot abort early the way the Codex/Claude - * scans do. What it does avoid is `readFile` + `split("\n")` over transcripts - * that reach tens of MB, which dominated the scan on a large `~/.workbuddy`. - * Only the fields the row renders are retained, so peak memory stays flat. - */ -async function summarizeFile(filePath) { - let firstTimestamp = null; - let lastTimestamp = null; - let aiTitle = null; - let firstUserText = null; - let cwd = null; - let model = null; - let convoCount = 0; - - const stream = createReadStream(filePath, { encoding: "utf8" }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - try { - for await (const raw of rl) { - const trimmed = raw.trim(); - if (!trimmed) continue; - let line; - try { - line = JSON.parse(trimmed); - } catch { - continue; // malformed line — skip - } - if (line.timestamp) { - if (!firstTimestamp) firstTimestamp = line.timestamp; - lastTimestamp = line.timestamp; - } - if (line.type === "ai-title" && line.aiTitle) { - aiTitle = line.aiTitle; // last one wins, matching summarize() - continue; - } - if (!cwd && line.cwd) cwd = line.cwd; - if (!isConversationLine(line)) continue; - convoCount += 1; - if (line.role === "assistant" && !model && line.providerData?.model) { - model = line.providerData.model; - } - if (!firstUserText && line.role === "user") { - const text = stripInjected(blockText(line.content)); - if (text) firstUserText = text; - } - } - } catch { - return null; // unreadable session file — skip - } finally { - rl.close(); - stream.destroy(); - } - if (convoCount === 0) return null; - - const externalId = path.basename(filePath, ".jsonl"); - return { - source: SOURCE, - externalId, - title: truncateTitle(aiTitle ?? "") || truncateTitle(firstUserText || "") || externalId, - fullTitle: String(aiTitle ?? firstUserText ?? ""), - projectName: projectNameOf(cwd) ?? "WorkBuddy", - projectPath: cwd, - modelId: model, - providerId: null, - createdAt: toIso(firstTimestamp), - updatedAt: toIso(lastTimestamp, toIso(firstTimestamp)), - messageCount: convoCount, - filePath, - }; -} - -async function scan() { - const projectsDir = projectsDirFor(); - let projectDirs = []; - try { - projectDirs = await fsp.readdir(projectsDir); - } catch { - return []; - } - const filesByDir = await Promise.all( - projectDirs.map(async (dir) => { - const dirPath = path.join(projectsDir, dir); - try { - const files = (await fsp.readdir(dirPath)).filter((f) => f.endsWith(".jsonl")); - return files.map((f) => path.join(dirPath, f)); - } catch { - return []; - } - }), - ); - const filePaths = filesByDir.flat(); - const found = await mapValuesWithConcurrency( - filePaths, - SCAN_CONCURRENCY, - (filePath) => summarizeFile(filePath), - ); - found.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); - return found; -} - -async function convert(summary) { - const projectsDir = projectsDirFor(); - const { messages } = await convertFile(summary.filePath, projectsDir); - // Re-derive metadata from the same file so titles/model stay accurate. - const lines = await readLines(summary.filePath); - const meta = summarize(summary.filePath, lines, messages); - return { - session: { - id: `import-workbuddy-${meta.externalId}`, - title: meta.fullTitle || meta.title, - projectPath: meta.projectPath, - modelId: meta.modelId, - providerId: null, - mode: "agent", - createdAt: meta.createdAt, - updatedAt: meta.updatedAt, - }, - messages, - }; -} - -module.exports = { source: SOURCE, label: "WorkBuddy", projectsDirFor, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/sources/zcode.js b/plugins/io.github.muzimu217.session-import/lib/sources/zcode.js deleted file mode 100644 index 738d7f5..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/sources/zcode.js +++ /dev/null @@ -1,168 +0,0 @@ -/** - * ZCode adapter: reads ~/.zcode/cli/db/db.sqlite (session -> message -> part) - * read-only via the built-in node:sqlite. Logic shared with the standalone - * ZCode plugin. - */ -"use strict"; - -const os = require("node:os"); -const path = require("node:path"); -const { toIso, truncateTitle, projectNameOf } = require("../util"); - -const SOURCE = "zcode"; - -const dbPathFor = (home = os.homedir()) => path.join(home, ".zcode", "cli", "db", "db.sqlite"); - -function parseJsonColumn(raw) { - if (raw == null) return null; - try { - return JSON.parse(raw); - } catch { - return null; - } -} - -function mapToolStatus(status) { - if (status === "error") return "error"; - if (status === "completed") return "success"; - return "running"; -} - -function openDb(dbPath) { - const { DatabaseSync } = require("node:sqlite"); - return new DatabaseSync(dbPath, { readOnly: true }); -} - -function scan() { - const dbPath = dbPathFor(); - const db = openDb(dbPath); - try { - const rows = db - .prepare( - `SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, - COALESCE(m.message_count, 0) AS message_count - FROM session s - LEFT JOIN ( - SELECT session_id, COUNT(*) AS message_count - FROM message - GROUP BY session_id - ) m ON m.session_id = s.id - WHERE s.task_type <> 'subagent_child'`, - ) - .all(); - const sessions = []; - for (const row of rows) { - if (!row.id || !row.message_count) continue; - sessions.push({ - source: SOURCE, - externalId: row.id, - title: truncateTitle(row.title) || row.id, - fullTitle: String(row.title ?? ""), - projectName: projectNameOf(row.directory) ?? "ZCode", - projectPath: row.directory ?? null, - modelId: null, - providerId: null, - createdAt: toIso(row.time_created), - updatedAt: toIso(row.time_updated, toIso(row.time_created)), - messageCount: Number(row.message_count), - filePath: dbPath, - }); - } - return sessions; - } finally { - db.close(); - } -} - -function convert(summary) { - const db = openDb(summary.filePath || dbPathFor()); - try { - const messageRows = db - .prepare( - `SELECT id, data, time_created FROM message - WHERE session_id = ? - ORDER BY sequence, time_created, id`, - ) - .all(summary.externalId); - const partRows = db - .prepare( - `SELECT message_id, data FROM part - WHERE session_id = ? - ORDER BY sequence, time_created, id`, - ) - .all(summary.externalId); - - const partsByMessage = new Map(); - for (const row of partRows) { - const part = parseJsonColumn(row.data); - if (!part?.type || !row.message_id) continue; - const bucket = partsByMessage.get(row.message_id) ?? []; - bucket.push(part); - partsByMessage.set(row.message_id, bucket); - } - - const messages = []; - let modelId = null; - let providerId = null; - - const flushText = (role, createdAt, texts) => { - const text = texts.join("\n").trim(); - texts.length = 0; - if (!text) return; - messages.push({ role, content: text, createdAt }); - }; - - for (const row of messageRows) { - const msg = parseJsonColumn(row.data); - if (!msg || !row.id) continue; - if (msg.role !== "user" && msg.role !== "assistant") continue; - const role = msg.role; - const createdAt = toIso(msg.time?.created ?? row.time_created); - if (role === "assistant") { - modelId = msg.modelID ?? modelId; - providerId = msg.providerID ?? providerId; - } - - // Emit parts in stored order so tool calls keep their place. - const texts = []; - for (const part of partsByMessage.get(row.id) ?? []) { - if (part.type === "text" && part.text && part.synthetic !== true) { - texts.push(part.text); - } else if (part.type === "tool") { - flushText(role, createdAt, texts); - const output = part.state?.output; - const outputText = - typeof output === "string" ? output : output ? JSON.stringify(output) : ""; - messages.push({ - role: "tool", - content: outputText, - toolName: part.tool ?? "tool", - toolStatus: mapToolStatus(part.state?.status), - toolArgs: part.state?.input ?? null, - toolResult: outputText, - createdAt, - }); - } - } - flushText(role, createdAt, texts); - } - - return { - session: { - id: `import-zcode-${summary.externalId}`, - title: summary.fullTitle || summary.title, - projectPath: summary.projectPath, - modelId, - providerId, - mode: "agent", - createdAt: summary.createdAt, - updatedAt: summary.updatedAt, - }, - messages, - }; - } finally { - db.close(); - } -} - -module.exports = { source: SOURCE, label: "ZCode", dbPathFor, scan, convert }; diff --git a/plugins/io.github.muzimu217.session-import/lib/util.js b/plugins/io.github.muzimu217.session-import/lib/util.js deleted file mode 100644 index c1ec201..0000000 --- a/plugins/io.github.muzimu217.session-import/lib/util.js +++ /dev/null @@ -1,68 +0,0 @@ -/** Shared helpers for all source adapters. */ -"use strict"; - -function toIso(value, fallback) { - if (value !== undefined && value !== null) { - const d = new Date(Number(value)); - if (!Number.isNaN(d.getTime())) return d.toISOString(); - // Also accepts ISO strings passthrough via Date parsing above only for - // epoch numbers; strings go through the generic parser below. - const d2 = new Date(value); - if (!Number.isNaN(d2.getTime())) return d2.toISOString(); - } - return fallback ?? new Date().toISOString(); -} - -function truncateTitle(text, max = 60) { - const t = String(text ?? "").replace(/\s+/g, " ").trim(); - if (!t) return ""; - return t.length > max ? `${t.slice(0, max)}…` : t; -} - -function projectNameOf(projectPath) { - if (!projectPath) return null; - const clean = String(projectPath).replace(/[\\/]+$/, ""); - return clean.split(/[\\/]/).pop() || null; -} - -/** - * Bounded-concurrency map that preserves input order. - * - * Scans are I/O bound (hundreds of multi-MB transcripts), so running them one - * at a time wastes the whole scan on read latency. `limit` caps how many file - * handles we hold open at once. A throwing worker resolves to `{ok:false}` so - * one bad file never sinks the batch — callers filter on `.ok`. - */ -async function mapWithConcurrency(items, limit, worker) { - const list = Array.isArray(items) ? items : []; - const results = new Array(list.length); - const width = Math.max(1, Math.min(Number(limit) || 1, list.length || 1)); - let cursor = 0; - const runners = Array.from({ length: width }, async () => { - while (cursor < list.length) { - const index = cursor; - cursor += 1; - try { - results[index] = { ok: true, value: await worker(list[index], index) }; - } catch (error) { - results[index] = { ok: false, error }; - } - } - }); - await Promise.all(runners); - return results; -} - -/** Same as {@link mapWithConcurrency} but returns the successful values only. */ -async function mapValuesWithConcurrency(items, limit, worker) { - const results = await mapWithConcurrency(items, limit, worker); - return results.filter((r) => r.ok).map((r) => r.value); -} - -module.exports = { - toIso, - truncateTitle, - projectNameOf, - mapWithConcurrency, - mapValuesWithConcurrency, -}; diff --git a/plugins/io.github.muzimu217.session-import/main.js b/plugins/io.github.muzimu217.session-import/main.js deleted file mode 100644 index e974991..0000000 --- a/plugins/io.github.muzimu217.session-import/main.js +++ /dev/null @@ -1,1045 +0,0 @@ -/** - * Universal Session Import — plugin entry. - * - * Host injects global `pi`. The work-panel view drives this process through - * custom `pluginBridge.invoke("import.*")` channels, forwarded to - * `onPanelInvoke`: - * - * import.scanSource {source} -> {found, count} (cached) - * import.sessions {source} -> {sessions} - * import.convertBatch {source, items:[summary]} -> {sessions:[ImportedSession]} - * - * Converted sessions are imported by the panel through the host - * "session.import" bridge, one call per session. - */ -"use strict"; - -const { - ADAPTERS, - getAdapter, - allAdapters, - refreshDynamicSources, - getDynamicLoadReport, - CONFIG_PATH, -} = require("./lib/registry"); -const { driverNames } = require("./lib/drivers"); -const forge = require("./lib/forge"); -const bus = require("./lib/bus"); -const history = require("./lib/history"); -const saveModes = require("./lib/save-modes"); - -/** Scan results cached per source so switching sources never rescans. */ -const cache = new Map(); - -async function onLoad() { - await pi.commands.register({ - id: "session-import.open", - title: "Session Import: Open Panel", - keywords: ["import", "导入", "会话"], - run: async () => { - await pi.ui.openPanel({ title: "一体化会话导入" }); - }, - }); - - // P0-F2: explicit "switch to forge" command usable from the importer UI - // or as a slash command. Opens the work-panel's forge view by its declared - // view id. - await pi.commands.register({ - id: "session-forge.show", - title: "Session Forge: Open Work-Panel View", - keywords: ["forge", "蒸馏", "panel"], - run: async () => { - const caps = forgeCapabilities(); - const missing = Object.entries(caps).filter(([, v]) => !v).map(([k]) => k); - if (missing.length) { - await pi.ui.showToast( - `会话熔炉:宿主缺少 ${missing.join(", ")},请升级或重新授权`, - "warn", - ); - return; - } - try { - await pi.ui.openPanel?.({ viewId: "session-forge" }); - } catch { - await pi.ui.showToast("会话熔炉:右侧工作面板已可打开「会话熔炉」", "info"); - } - }, - }); - - // 熔炉是 docked view,不是独立窗口;命令只做提示与自检。 - await pi.commands.register({ - id: "session-forge.open", - title: "Session Forge: Distill Imported Sessions", - keywords: ["forge", "蒸馏", "distill", "规则", "skill"], - run: async () => { - const caps = forgeCapabilities(); - const missing = Object.entries(caps).filter(([, v]) => !v).map(([k]) => k); - await pi.ui.showToast( - missing.length - ? `会话熔炉:宿主缺少 ${missing.join(", ")},请升级或重新授权` - : "会话熔炉:在右侧工作面板打开「会话熔炉」", - missing.length ? "warn" : "info", - ); - }, - }); - - // User-defined sources live in docs/session-import-sources.json and are read - // through the host fs bridge, so they stay inside the declared fs.read scope. - // A missing or invalid config simply means "built-in sources only". - try { - await refreshDynamicSources({ readText: async (rel) => pi.fs.readText(rel) }); - } catch { - /* no workspace / no config -> built-in sources only */ - } -} - -async function onUnload() { - await pi.commands.unregister("session-import.open"); - await pi.commands.unregister("session-forge.open"); - await pi.commands.unregister("session-forge.show"); -} - -async function onPanelInvoke(channel, payload) { - switch (channel) { - case "import.adapters": - return allAdapters().map((a) => ({ - source: a.source, - label: a.label, - custom: a.custom === true, - dataPath: a.dataPath || null, - })); - case "import.customSources": { - const report = getDynamicLoadReport(); - return { - configPath: report.configPath || CONFIG_PATH, - drivers: driverNames(), - count: report.count ?? 0, - errors: report.errors ?? [], - }; - } - case "import.reloadCustomSources": { - try { - await refreshDynamicSources({ readText: async (rel) => pi.fs.readText(rel) }); - } catch (err) { - return { - configPath: CONFIG_PATH, - drivers: driverNames(), - count: 0, - errors: [String((err && err.message) || err)], - }; - } - const report = getDynamicLoadReport(); - return { - configPath: report.configPath || CONFIG_PATH, - drivers: driverNames(), - count: report.count ?? 0, - errors: report.errors ?? [], - }; - } - case "import.scanSource": - return scanSource(payload); - case "import.sessions": - return sessions(payload); - case "import.convertBatch": - return convertBatch(payload); - case "import.capabilities": - return { officialSessionApi: officialSessionApi() !== null }; - case "import.commit": { - const source = String(payload?.source ?? ""); - const items = Array.isArray(payload?.items) ? payload.items : []; - // Prefer the official batch API when the host advertises it. The - // plugin SDK exposes `pi.session.importBatch` as a callable even when - // the host has not implemented the backing RPC, so we must guard on the - // *runtime* error ("host api not available") rather than a typeof check. - const official = officialSessionApi(); - if (official) { - try { - // Default to project binding: a source session that records a project - // path is bound to that project by the host (ensure_project on the - // path). Only an explicit "standalone" placement skips binding, which - // is the data-loss-safe fallback for sessions with no projectPath. - const placement = payload?.placement === "standalone" ? "standalone" : "project"; - return await commitOfficial(source, items, placement); - } catch (e) { - if (!hostLacksImportApi(e)) throw e; // genuine failure — surface it - // host advertised importBatch but rejected the call → legacy bridge - } - } - // Legacy single-session bridge (local dev host with session.import wired, - // e.g. feat/zcode-session-import). Catches missing-host-API and reports a - // clear error instead of a cryptic crash. - try { - return await commitLegacy(source, items); - } catch (e) { - if (hostLacksImportApi(e) || /HOST_LACKS_IMPORT/.test(String(e?.message ?? e))) { - throw new Error( - "当前宿主未提供会话导入接口(session.import / importBatch)。" + - "请使用接入了导入桥接的宿主构建(feat/zcode-session-import 分支)," + - "或升级到支持官方 pi.session.importBatch 的宿主版本。", - ); - } - throw e; - } - } - // --- P0-F1: cross-view state — forge polls this on each refresh --- - case "import.recent": - return { - lastImportAt: bus.recentImport.at ? bus.recentImport.at.toISOString() : null, - lastImportCount: bus.recentImport.count, - lastImportSource: bus.recentImport.source, - }; - // --- P1-U1: best-effort host locale so the work-panel views can pick - // en / zh-CN without guessing. Falls back to zh-CN when the host SDK - // does not expose a locale (older builds). --- - case "app.locale": - return bestEffortLocale(); - // --- Forge: 会话蒸馏(读回本插件导入的行 -> 宿主 one-shot 补全)--- - case "forge.capabilities": - return forgeCapabilities(); - case "forge.sessions": - return forgeSessions(payload); - case "forge.messages": - return forgeMessages(payload); - case "forge.models": - return forgeModels(); - case "forge.distill": - return forgeDistill(payload); - case "forge.save": - return forgeSave(payload); - // --- P1-F3: distillation history (JSONL ring buffer under docs/**) --- - case "forge.history.load": - return { entries: await loadHistory() }; - case "forge.history.record": - return { count: await recordHistory(payload?.entry ?? {}) }; - default: - throw new Error(`unknown channel: ${channel}`); - } -} - -// --------------------------------------------------------------------------- -// Locale — P1-U1 -// --------------------------------------------------------------------------- - -/** Resolve the host's UI locale, preferring SDK-provided values. */ -function bestEffortLocale() { - const raw = pi?.i18n?.locale || pi?.app?.locale || pi?.locale; - if (typeof raw === "string" && /^(en|zh-CN|zh)$/i.test(raw.trim())) { - const v = raw.trim().toLowerCase(); - return v === "zh" ? "zh-CN" : v; - } - return "zh-CN"; -} - -// --------------------------------------------------------------------------- -// Forge — 会话熔炉 -// --------------------------------------------------------------------------- - -function forgeCapabilities() { - return { - listSessions: typeof pi?.session?.list === "function", - listMessages: typeof pi?.session?.listMessages === "function", - complete: typeof pi?.agent?.complete === "function", - models: typeof pi?.models?.list === "function", - writeText: typeof pi?.fs?.writeText === "function", - }; -} - -async function forgeSessions(payload) { - const sessions = await forge.listImported({ - source: payload?.source ? String(payload.source) : undefined, - limit: payload?.limit, - }); - return { sessions }; -} - -async function forgeMessages(payload) { - const sessionId = String(payload?.sessionId ?? ""); - if (!sessionId) throw new Error("sessionId required"); - const messages = await forge.readMessages(sessionId, { limit: payload?.limit }); - return { sessionId, messages }; -} - -async function forgeModels() { - const rows = await pi.models.list(); - return { models: Array.isArray(rows) ? rows : (rows?.models ?? []) }; -} - -async function forgeDistill(payload) { - const sessionIds = Array.isArray(payload?.sessionIds) ? payload.sessionIds : []; - if (sessionIds.length === 0) throw new Error("no sessions selected"); - - // P1#3: 蒸馏必须显式带非空的 modelKey,否则宿主拒收 INVALID_ARGUMENT - const modelKey = payload?.modelKey || undefined; - if (!modelKey) throw new Error("请先在上方选择一个蒸馏模型(modelKey 不能为空)"); - - // 逐个取回消息;单会话失败不阻断整批。 - const sessions = []; - let unreadable = 0; - for (const id of sessionIds) { - try { - const meta = payload?.sessions?.find((s) => String(s.id) === String(id)) ?? { id }; - const messages = await forge.readMessages(String(id)); - sessions.push({ ...meta, id: String(id), messages }); - } catch { - unreadable += 1; - } - } - if (sessions.length === 0) throw new Error("selected sessions could not be read"); - - const result = await forge.distill({ - sessions, - modelKey, - thinkingLevel: payload?.thinkingLevel || undefined, - goal: payload?.goal ? String(payload.goal) : undefined, - }); - - return { ok: true, distilled: result.text, usage: result.usage, unreadable }; -} - -async function forgeSave(payload) { - const path = String(payload?.path ?? ""); - const content = String(payload?.content ?? ""); - const mode = saveModes.normalize(payload?.mode); - if (!path) throw new Error("path required"); - if (!content.trim()) throw new Error("nothing to save"); - // P1-F4: overwrite replaces; append/merge need the current file content. - let existing = ""; - if (mode !== "overwrite") { - try { - existing = await pi.fs.readText(path); - } catch { - existing = ""; // file not present yet, or read denied — start fresh - } - } - const final = saveModes.combine(existing, content, mode); - await pi.fs.writeText(path, final); - const bytes = Buffer.byteLength(final, "utf8"); - // P1-F3: record a history entry for every successful save. - await recordHistory({ - path, - mode, - bytes, - at: new Date().toISOString(), - modelKey: payload?.modelKey || null, - goal: payload?.goal || null, - }); - return { ok: true, path, bytes, mode }; -} - -// --- P1-F3: history persistence (read-modify-write under docs/**) --- - -async function loadHistory() { - try { - return history.parseJsonl(await pi.fs.readText(history.HISTORY_PATH)); - } catch { - return []; - } -} - -async function recordHistory(entry) { - const entries = history.addEntry(await loadHistory(), entry); - try { - await pi.fs.writeText(history.HISTORY_PATH, history.toJsonl(entries)); - } catch { - // persistence is best-effort; in-memory entries still returned - } - return entries.length; -} - -/** - * Scan a source. Adapters may expose `scanFast()` for a cheap first pass (the - * Codex adapter does: its rollout tree can hold hundreds of multi-MB - * transcripts). When one exists we return that immediately, mark the entry - * `partial`, and queue the full scan for later. - * - * The full scan is deliberately NOT started while other sources are still - * scanning: it reads GBs off the same disk and starves the panels the user is - * actually waiting on (measured: a concurrent WorkBuddy scan went 1.7s → 9.5s - * behind Codex's background pass). It runs once the foreground queue drains. - */ -async function scanSource(payload) { - const source = String(payload?.source ?? ""); - const adapter = getAdapter(source); - if (!adapter) throw new Error(`unknown source: ${source}`); - const wantsFull = payload?.full === true; - if (cache.has(source) && !wantsFull) { - const sessions = cache.get(source); - // Cached entries keep their original error (frozen at scan time) so the - // panel can still show why the first scan failed. - const error = cacheError.get(source) ?? null; - return { - source, - found: sessions.length > 0, - count: sessions.length, - error, - partial: cachePartial.get(source) === true, - }; - } - - const useFast = !wantsFull && typeof adapter.scanFast === "function" && !cache.has(source); - let sessions = []; - let error = null; - foregroundScans += 1; - try { - const scanFn = useFast ? adapter.scanFast : adapter.scan; - sessions = await withTimeout(() => scanFn.call(adapter), SCAN_TIMEOUT_MS, `${source}.scan`); - } catch (e) { - error = { - code: e?.code ?? "UNKNOWN", - message: String(e?.message ?? e), - }; - sessions = []; - } finally { - foregroundScans -= 1; - // Only the fast branch queues work, but any scan finishing can be the last - // one outstanding — e.g. Codex's fast pass returns first and queues, then - // Claude/WorkBuddy finish a second later and unblock the queue. - if (foregroundScans === 0) scheduleFullScans(); - } - // C-robustness: drop duplicate summaries (same source+externalId) so the - // panel never previews or imports the same session twice, and flag sessions - // that will hit the host's 2000-message cap so the UI can warn about likely - // truncation. The host is idempotent on externalId, but dedupe keeps the UI - // honest about what it will actually import. - const { deduped, oversizedCount } = dedupeSummaries(sessions, source); - sessions = deduped; - cache.set(source, sessions); - cacheError.set(source, error); - cachePartial.set(source, useFast); - if (useFast) { - pendingFullScans.push({ source, adapter }); - // A later call serves the completed list straight from the cache. - scheduleFullScans(); - } - return { - source, - found: sessions.length > 0, - count: sessions.length, - error, - partial: useFast, - oversized: oversizedCount, - }; -} - -/** - * Pure helper backing {@link scanSource}: remove duplicate summaries by - * `source:externalId` and flag sessions whose `messageCount` exceeds the host's - * 2000-message cap (they will be truncated on import). Exported for tests. - */ -function dedupeSummaries(sessions, source) { - const seen = new Set(); - const deduped = []; - let oversizedCount = 0; - for (const s of sessions) { - const key = `${s.source || source}:${s.externalId}`; - if (seen.has(key)) continue; - seen.add(key); - if (typeof s.messageCount === "number" && s.messageCount > CONTRACT.messagesMax) { - s.oversized = true; - oversizedCount += 1; - } - deduped.push(s); - } - return { deduped, oversizedCount }; -} - -/** Run queued full scans once no foreground scan is outstanding. */ -function scheduleFullScans() { - if (foregroundScans > 0 || pendingFullScans.length === 0) return; - const next = pendingFullScans.shift(); - void warmFullScan(next.source, next.adapter).finally(() => { - if (pendingFullScans.length > 0) scheduleFullScans(); - }); -} - -/** Background full scan used to promote a partial result to a complete one. */ -async function warmFullScan(source, adapter) { - if (fullScanInFlight.has(source)) return; - fullScanInFlight.add(source); - try { - const sessions = await withTimeout( - () => adapter.scan(), - FULL_SCAN_TIMEOUT_MS, - `${source}.scanFull`, - ); - cache.set(source, sessions); - cachePartial.set(source, false); - if (typeof pi?.events?.emit === "function") { - pi.events.emit("import.scanReady", { source, count: sessions.length }); - } - } catch { - // Leave the partial list in place; the next explicit scan retries. - } finally { - fullScanInFlight.delete(source); - } -} - -const cachePartial = new Map(); -const fullScanInFlight = new Set(); -const pendingFullScans = []; -let foregroundScans = 0; - -// Parallel cache for scan errors so re-asking the same source preserves the -// original failure (avoid failing twice); never expires alongside the -// session cache. -const cacheError = new Map(); - -function sessions(payload) { - const source = String(payload?.source ?? ""); - if (!cache.has(source)) throw new Error(`source not scanned: ${source}`); - return { source, sessions: cache.get(source) }; -} - -async function convertBatch(payload) { - const source = String(payload?.source ?? ""); - const adapter = getAdapter(source); - if (!adapter) throw new Error(`unknown source: ${source}`); - const items = Array.isArray(payload?.items) ? payload.items : []; - if (items.length === 0) throw new Error("nothing selected to import"); - - const sessions = []; - let unreadable = 0; - for (const item of items) { - try { - const conv = await withTimeout( - () => adapter.convert(item), - CONVERT_TIMEOUT_MS, - `${source}.convert`, - ); - // Host UiMessage requires an id on every message; adapters describe - // content only, so allocate ids here in one place. - conv.messages = conv.messages.map((m) => ({ - id: crypto.randomUUID(), - ...m, - })); - sessions.push(conv); - } catch { - unreadable += 1; - } - } - return { ok: true, source, sessions, unreadable }; -} - -// --------------------------------------------------------------------------- -// Official session API (PI-Desktop #169, P0/P1 — pi.session.importBatch). -// Falls back to the legacy dev-build "session.import" bridge when the host -// does not provide the official API yet. -// --------------------------------------------------------------------------- - -const CONTRACT = { - titleMax: 200, - externalIdMax: 256, - contentMax: 512 * 1024, // 512 KiB per message content - toolPayloadMax: 256 * 1024, // 256 KiB serialized toolArgs/toolResult - jsonDepthMax: 8, - batchSessionsMax: 100, // importBatch hard limit per call - messagesMax: 2000, // official LIMIT_EXCEEDED guard (origin/main plugin-runtime) -}; - -// The host measures every size limit in UTF-8 BYTES -// (plugin-runtime.ts: new TextEncoder().encode(content).byteLength), not in -// UTF-16 code units. CJK / emoji messages weigh 3-4 bytes per character, so a -// naive `String.slice(0, 512 * 1024)` can be 3x over the limit and the host -// rejects the whole batch with "message content exceeds 512 KiB". Keep a small -// margin so encoding overhead on our side never tips it over. -const SIZE_SAFETY_MARGIN = 1024; - -/** - * Truncate a string to at most `maxBytes` UTF-8 bytes without leaving a - * broken surrogate pair at the cut. Binary-search the boundary so huge - * transcripts (multi-MB tool results) stay cheap. - */ -const CONVERT_CONCURRENCY = 8; - -/** Run `worker` over `items` with bounded concurrency, preserving order. */ -async function mapWithConcurrency(items, limit, worker) { - const results = new Array(items.length); - let cursor = 0; - const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (cursor < items.length) { - const index = cursor; - cursor += 1; - try { - results[index] = { ok: true, value: await worker(items[index]) }; - } catch (error) { - results[index] = { ok: false, error }; - } - } - }); - await Promise.all(runners); - return results; -} - -// --------------------------------------------------------------------------- -// Timeout watchdog — C: a single source's scan/convert must never be able to -// hang the whole panel. A hung `adapter.scan()` (corrupt 224MB db, a network- -// mounted transcript, an un-closed read stream) would otherwise leave -// `foregroundScans` permanently > 0, which blocks `scheduleFullScans()` from -// ever firing and freezes the "scanning" spinner forever. We race every -// scan/convert against a deadline and surface a clean TIMEOUT error instead. -// --------------------------------------------------------------------------- - -const SCAN_TIMEOUT_MS = 90 * 1000; // foreground scan (user is waiting on it) -const FULL_SCAN_TIMEOUT_MS = 5 * 60 * 1000; // background full pass (non-blocking) -const CONVERT_TIMEOUT_MS = 30 * 1000; // one session's convert - -/** Error raised when a guarded operation exceeds its deadline. */ -class TimeoutError extends Error { - constructor(label, ms) { - super(`${label} 在 ${ms}ms 内未完成(已超时)`); - this.code = "TIMEOUT"; - this.name = "TimeoutError"; - } -} - -/** - * Run `factory()` and reject after `ms` if it has not settled. - * - * Adapters are a mix of sync (`zcode.scan` reads the DB synchronously) and - * async (`workbuddy.scan` fans out over the filesystem). A synchronous return - * cannot be interrupted — but those paths are bounded DB reads, not the hang - * risk — so we pass it through untouched. Only genuine promises are raced. - * - * The underlying promise is intentionally NOT aborted (Node has no cheap way to - * cancel arbitrary I/O); we merely stop waiting on it. It keeps running - * read-only in the background and is harmless once the panel has moved on. - */ -function withTimeout(factory, ms, label) { - let result; - try { - result = factory(); - } catch (error) { - return Promise.reject(error); - } - if (!result || typeof result.then !== "function") return Promise.resolve(result); - if (!Number.isFinite(ms) || ms <= 0) return result; - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms); - }); - return Promise.race([result, timeout]).finally(() => clearTimeout(timer)); -} - -/** - * Bytes a value occupies once the host re-serializes it. - * - * The host validates `TextEncoder().encode(JSON.stringify(value)).byteLength`, - * NOT the raw string length. JSON escaping inflates control characters, quotes - * and backslashes by up to ~44% (measured), so budgeting a 256 KiB *raw* - * string can still produce a >256 KiB serialized value and get the whole batch - * rejected with "toolResult exceeds 256 KiB". - */ -function serializedBytes(value) { - try { - const serialized = JSON.stringify(value); - if (serialized === undefined) return 0; - return Buffer.byteLength(serialized, "utf8"); - } catch { - return Number.POSITIVE_INFINITY; - } -} - -/** - * Truncate a string so its **JSON-serialized** form fits `maxBytes`. - * - * `serializedBytes` counts the value, so the shrinking is done by binary - * search on the serialized length rather than the raw byte length. Escaping can - * only shrink in proportion as characters are removed, so this converges. - */ -function truncateToSerializedBytes(text, maxBytes) { - const value = String(text ?? ""); - if (!value) return value; - const budget = Math.max(0, maxBytes - SIZE_SAFETY_MARGIN); - if (serializedBytes(value) <= budget) return value; - let lo = 0; - let hi = value.length; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (serializedBytes(value.slice(0, mid)) <= budget) lo = mid; - else hi = mid - 1; - } - let cut = value.slice(0, lo); - // Drop a trailing lone high surrogate so the payload stays valid UTF-8. - if (cut.length > 0) { - const last = cut.charCodeAt(cut.length - 1); - if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); - } - return cut; -} - -/** - * Raw-byte truncation, for places that are NOT re-serialized by the host - * (nothing today — kept for callers that need a pure byte budget). - */ -function truncateBytes(text, maxBytes) { - const value = String(text ?? ""); - if (!value) return value; - const budget = Math.max(0, maxBytes - SIZE_SAFETY_MARGIN); - if (Buffer.byteLength(value, "utf8") <= budget) return value; - let lo = 0; - let hi = value.length; - while (lo < hi) { - const mid = (lo + hi + 1) >> 1; - if (Buffer.byteLength(value.slice(0, mid), "utf8") <= budget) lo = mid; - else hi = mid - 1; - } - let cut = value.slice(0, lo); - // Drop a trailing lone high surrogate so the payload stays valid UTF-8. - if (cut.length > 0) { - const last = cut.charCodeAt(cut.length - 1); - if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); - } - return cut; -} - -/** - * Official validation (origin/main plugin-runtime.ts validatePluginSessionPayload) - * rejects: empty/blank titles (>200 code points), missing externalId, non-RFC3339 - * timestamps, createdAt > updatedAt, non-monotonic message times, >2000 messages, - * roles outside user/assistant/tool, content > 512 KiB. One bad item fails the - * WHOLE batch (fail semantics), so pre-validate here and skip the bad item - * instead of poisoning the batch. - */ -function contractViolation(item, conv) { - const title = String(conv.session.title ?? item.title ?? ""); - if (!title.trim()) return "empty title"; - const externalId = String(item.externalId ?? conv.session.id ?? ""); - if (!externalId.trim()) return "empty externalId"; - return null; -} - -function officialSessionApi() { - return typeof pi?.session?.importBatch === "function" ? pi.session : null; -} - -/** - * True when the host rejected a session-import call because the API is not - * actually wired up. The plugin SDK surfaces `pi.session.importBatch` / - * `pi.session.import` as callables regardless, so the only reliable signal - * is the runtime error the host emits ("host api not available", - * "UNSUPPORTED", or an unhandled panel channel). - */ -function hostLacksImportApi(e) { - const s = e?.message ? String(e.message) : String(e ?? ""); - return /host api not available/i.test(s) || /UNSUPPORTED/i.test(s) || /unknown channel/i.test(s); -} - -function parseIsoMs(iso, fallbackMs) { - const t = Date.parse(iso); - return Number.isNaN(t) ? fallbackMs : t; -} - -function toIso(ms) { - return new Date(ms).toISOString(); -} - -function jsonDepth(value, depth = 1) { - if (value === null || typeof value !== "object") return depth; - let max = depth; - for (const v of Object.values(value)) max = Math.max(max, jsonDepth(v, depth + 1)); - return max; -} - -// The official validator counts depth over the WHOLE batch input: -// input(1) → sessions(2) → session(3) → messages(4) → message(5) → toolArgs… -// so a tool payload's own depth budget is 8 − 5 = 3. Anything deeper is -// stringified (depth 1) — losing structure but never poisoning the batch. -const TOOL_PAYLOAD_DEPTH_MAX = 3; - -/** Keep tool payloads inside contract size/depth limits without dropping data. */ -function safeToolPayload(value) { - if (value === null || value === undefined) return undefined; - const simple = typeof value !== "object"; - const serialized = JSON.stringify(value); - if (serialized === undefined) return undefined; - - if (Buffer.byteLength(serialized, "utf8") > CONTRACT.toolPayloadMax) { - // Oversized by raw measure. The host re-serializes whatever we send, so a - // truncated *string* must still fit once JSON escaping is applied — which - // can add ~44%. Truncating the stringified form as a string budgets - // against that escaped size directly. - return truncateToSerializedBytes(serialized, CONTRACT.toolPayloadMax); - } - if (!simple && jsonDepth(value) > TOOL_PAYLOAD_DEPTH_MAX) { - // Too deep for the host's depth budget; a string is depth-1. Guard its - // serialized size too, since stringifying may itself expand. - return truncateToSerializedBytes(serialized, CONTRACT.toolPayloadMax); - } - return value; -} - -/** Map one converted session onto the #169 `importBatch` input shape. */ -function toContractSession(item, conv, projectId) { - // createdAt ≤ updatedAt; invalid timestamps degrade to the session bound. - const createdMs = parseIsoMs( - conv.session.createdAt ?? item.createdAt, - parseIsoMs(conv.session.updatedAt ?? item.updatedAt, Date.now()), - ); - const updatedMs = Math.max( - parseIsoMs(conv.session.updatedAt ?? item.updatedAt, createdMs), - createdMs, - ); - const createdAt = toIso(createdMs); - const updatedAt = toIso(updatedMs); - - let prevMs = createdMs; // message times must be monotonic non-decreasing - const messages = []; - for (const m of conv.messages) { - if (m.role !== "user" && m.role !== "assistant" && m.role !== "tool") continue; - if (messages.length >= CONTRACT.messagesMax) break; // official hard limit - const atMs = Math.max(parseIsoMs(m.createdAt, prevMs), prevMs); - prevMs = atMs; - const msg = { - role: m.role, - // Budgeted against the serialized size: the host measures - // JSON.stringify(content), which escaping can inflate well past the raw - // byte count. - content: truncateToSerializedBytes(m.content ?? "", CONTRACT.contentMax), - createdAt: toIso(atMs), - }; - if (m.role === "tool") { - msg.toolName = String(m.toolName ?? "tool"); - msg.toolCallId = String(m.toolCallId ?? m.id ?? crypto.randomUUID()); - msg.toolStatus = m.toolStatus === "error" ? "error" : "success"; - const args = safeToolPayload(m.toolArgs); - const result = safeToolPayload(m.toolResult); - if (args !== undefined) msg.toolArgs = args; - if (result !== undefined) msg.toolResult = result; - } - messages.push(msg); - } - - const session = { - externalId: String(item.externalId ?? conv.session.id).slice(0, CONTRACT.externalIdMax), - title: String(conv.session.title ?? item.title ?? "").slice(0, CONTRACT.titleMax), - // Sessions imported from a source that records a project path are bound to - // that project: the host's import_session runs ensure_project(path) whenever - // projectPath is non-empty, so simply carrying it is enough to bind — no - // projectId is needed from the plugin. Sessions with no projectPath carry - // neither field and fall back to the standalone SESSIONS list via the NULL - // project_id index, so data is never lost. - projectPath: conv.session.projectPath ?? null, - modelId: conv.session.modelId ?? null, - providerId: conv.session.providerId ?? null, - createdAt, - updatedAt, - messages, - }; - // A host-created projectId (when resolveProjectId succeeded) is sent along so - // the session binds to the exact project row; when project.create is - // unavailable the host still binds via projectPath alone. Sessions with no - // projectPath carry neither field and land in the standalone SESSIONS list. - if (projectId !== undefined && projectId !== null) session.projectId = projectId; - const enforced = enforceContractLimits(session); - // A session whose message count was capped at the host's 2000-message hard - // limit also lost content, so flag it as truncated alongside the byte-cap - // case so the user gets warned either way. - const messageCapped = conv.messages.length > messages.length; - return { session: enforced.session, truncated: enforced.truncated || messageCapped }; -} - -/** - * Last line of defence against the host's per-field limits. - * - * The host rejects the ENTIRE batch for one over-limit field, so a single - * pathological message would otherwise cost the user every session in the - * import. Everything above already truncates, but the limits are expressed in - * serialized bytes and escaping is easy to underestimate; this re-measures - * against exactly what the host computes and hard-clamps anything that still - * does not fit. - */ -function enforceContractLimits(session) { - let truncated = false; - for (const message of session.messages) { - const contentBytes = serializedBytes(message.content); - if (contentBytes > CONTRACT.contentMax) { - message.content = truncateToSerializedBytes( - String(message.content ?? ""), - CONTRACT.contentMax, - ); - truncated = true; - } - if (message.role !== "tool") continue; - for (const field of ["toolArgs", "toolResult"]) { - const value = message[field]; - if (value === undefined) continue; - const bytes = serializedBytes(value); - if (bytes <= CONTRACT.toolPayloadMax) continue; - // Truncate through the stringified form so the escaped size is what we - // budget against. - const serialized = JSON.stringify(value) ?? ""; - message[field] = truncateToSerializedBytes(serialized, CONTRACT.toolPayloadMax); - truncated = true; - } - } - return { session, truncated }; -} - -/** - * Resolve (and memoize) a host project id per project path. The host's - * project.create is idempotent for an existing path (returns the same row), - * so calling it once per distinct path per import is safe. - */ -const projectIdByPath = new Map(); -async function resolveProjectId(path) { - if (!path) return null; - if (projectIdByPath.has(path)) return projectIdByPath.get(path); - let id = null; - if (typeof pi?.project?.create === "function") { - try { - const res = await pi.project.create({ path }); - id = Number(res?.projectId ?? res?.id) || null; - } catch { - id = null; // host without project.create: fall back to unbound import - } - } - projectIdByPath.set(path, id); - return id; -} - -/** - * Convert + import the selected summaries through the official API. - * Chunked at the contract batch limit; aggregates importBatch results. - */ -async function commitOfficial(source, items, placement) { - const adapter = getAdapter(source); - if (!adapter) throw new Error(`unknown source: ${source}`); - const bindProjects = placement === "project"; - - // Conversion is dominated by file IO (whole transcripts are read and - // parsed), so run it with a small concurrency pool instead of serially — - // a 60-session Claude Code import dropped from ~50s to a few seconds. - const startedAt = Date.now(); - const converted = await mapWithConcurrency(items, CONVERT_CONCURRENCY, (item) => - withTimeout(() => adapter.convert(item), CONVERT_TIMEOUT_MS, `${source}.convert`), - ); - const convertedAt = Date.now(); - const convertMs = convertedAt - startedAt; - - // Opt-in project binding: resolve each host projectId once per distinct - // path (memoized inside resolveProjectId), so project.create stays - // idempotent. Done before the (synchronous) payload assembly below so the - // byte-costly truncation work stays off the await path. - if (bindProjects) { - const paths = new Set(); - for (const outcome of converted) { - const path = outcome?.ok ? outcome.value?.session?.projectPath : null; - if (path) paths.add(path); - } - for (const path of paths) await resolveProjectId(path); - } - - const contractSessions = []; - let unreadable = 0; - let truncated = 0; // sessions that lost content to the host's byte caps - for (let i = 0; i < items.length; i += 1) { - const outcome = converted[i]; - if (!outcome?.ok) { - unreadable += 1; - continue; - } - const conv = outcome.value; - if (contractViolation(items[i], conv)) { - unreadable += 1; // skip poisoned item instead of failing the batch - continue; - } - const projectId = bindProjects - ? projectIdByPath.get(conv.session.projectPath) ?? null - : null; - const built = toContractSession(items[i], conv, projectId); - contractSessions.push(built.session); - if (built.truncated) truncated += 1; - } - const builtAt = Date.now(); - - let imported = 0; - let skipped = 0; - const failed = []; - const batchTimes = []; - for (let i = 0; i < contractSessions.length; i += CONTRACT.batchSessionsMax) { - const chunk = contractSessions.slice(i, i + CONTRACT.batchSessionsMax); - const bt = Date.now(); - const res = await officialSessionApi().importBatch({ source, sessions: chunk, mode: "skip" }); - batchTimes.push({ n: chunk.length, ms: Date.now() - bt }); - imported += res.imported ?? 0; - skipped += res.skipped ?? 0; - for (const r of res.results ?? []) { - if (r.status === "failed") { - failed.push({ externalId: r.externalId, error: r.errorMessage ?? r.errorCode ?? "failed" }); - } - } - } - const finishedAt = Date.now(); - // P0-F1: record this batch on the cross-view bus so the forge view can - // surface a "just imported N sessions" hint on next refresh. - if (imported > 0) { - bus.recordImport({ count: imported, source }); - } - return { - ok: failed.length === 0, - imported, - skipped, - failed, - unreadable, - truncated, - timings: { - convertMs, - buildMs: builtAt - convertedAt, - importMs: finishedAt - builtAt, - batches: batchTimes, - }, - }; -} - -/** - * Legacy single-session import through the dev-host "session.import" bridge - * (host-core RPC `session.import`, gated behind `services.importSession` on - * feat/zcode-session-import). One `pi.session.import` call per converted - * session; per-item failures are collected rather than aborting the batch. - */ -async function commitLegacy(source, items) { - const adapter = getAdapter(source); - if (!adapter) throw new Error(`unknown source: ${source}`); - if (!pi?.session || typeof pi.session.import !== "function") { - throw new Error("HOST_LACKS_IMPORT: 当前宿主未暴露 session.import 桥接"); - } - - let imported = 0; - let skipped = 0; - let unreadable = 0; - const failed = []; - for (const item of items) { - try { - const conv = await withTimeout( - () => adapter.convert(item), - CONVERT_TIMEOUT_MS, - `${source}.convert`, - ); - const messages = (conv.messages || []).map((m) => ({ id: crypto.randomUUID(), ...m })); - // Plain host API call — bound through a destructured local so the source - // never spells the `import(` dynamic-module pattern that the - // plugin-center source audit greps for. Nothing is loaded dynamically. - const { import: runSessionImport } = pi.session; - const res = await runSessionImport({ session: conv.session, messages }); - if (res?.imported) imported += 1; - else skipped += 1; - } catch (e) { - failed.push({ externalId: item?.externalId ?? item?.id, error: String(e?.message ?? e) }); - } - } - // P0-F1: surface the just-imported count on the forge view. - if (imported > 0) bus.recordImport({ count: imported, source }); - return { ok: failed.length === 0, imported, skipped, failed, unreadable }; -} - -module.exports = { - onLoad, - onUnload, - onPanelInvoke, - // Exported for the self-test harness (tools/selftest-sources.cjs). - withTimeout, - dedupeSummaries, - enforceContractLimits, - toContractSession, - truncateToSerializedBytes, -}; diff --git a/plugins/io.github.muzimu217.session-import/manifest.json b/plugins/io.github.muzimu217.session-import/manifest.json deleted file mode 100644 index 11ea86e..0000000 --- a/plugins/io.github.muzimu217.session-import/manifest.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "schemaVersion": 1, - "id": "io.github.muzimu217.session-import", - "name": "Universal Session Import", - "version": "0.4.9", - "description": "一体化会话导入与熔炉:扫描本机的 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 并导入会话;可用声明式 JSON 自定义来源扩展;再读回这些会话,用宿主额度蒸馏成项目约定与可复用做法。", - "i18n": { - "en": { - "name": "Universal Session Import", - "description": "Import sessions from ZCode, WorkBuddy, Claude Code, Codex, OpenCode and Pi into PI-Desktop; extend it with declarative JSON custom sources; then distill those sessions back into project conventions and reusable practices.", - "safetyNotes": "Reads each tool's local session database in read-only mode (Node node:sqlite, readOnly). Writes sessions through the host's session import API. The forge calls the host model using the host's own credentials and quota — this plugin never sees or stores API keys. fs.write is used only to save distilled output, scoped to AGENTS.md, *.md, docs/** and .agents/skills/** in the workspace. No network requests, no telemetry. Custom source specs (docs/session-import-sources.json in the workspace) are declarative data only — no code from a config file is ever executed." - }, - "zh-CN": { - "name": "一体化会话导入", - "description": "一体化会话导入与熔炉:扫描本机的 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 并导入会话;可用声明式 JSON 自定义来源扩展;再读回这些会话,用宿主额度蒸馏成项目约定与可复用做法。", - "safetyNotes": "以只读方式打开各工具的本地会话数据库(Node node:sqlite 的 readOnly 模式);会话写入走宿主 session 导入接口。熔炉调用宿主模型,使用宿主自身的凭据与额度,本插件不接触也不保存任何 API Key。fs.write 仅用于保存蒸馏结果,范围限定在工作区内的 AGENTS.md、*.md、docs/**、.agents/skills/**。不发起任何网络请求,无遥测上报。 自定义来源配置(工作区 docs/session-import-sources.json)只接受纯数据,插件永不执行其中的代码。" - } - }, - "categories": [ - "developer-tools", - "productivity", - "import" - ], - "changelog": "Release 0.4.9(OpenCode v1.x Windows 路径修复,#682):OpenCode 适配器改为多候选路径探测——Windows 应用数据目录 %LOCALAPPDATA%\\opencode\\data\\(OpenCode 官方确认的位置)、XDG_DATA_HOME(若设置)、以及 ~/.local/share/opencode 默认位置,按序探测第一个存在的库。此前只探测 ~/.local/share/opencode/opencode.db,Windows 用户的 OpenCode v1.x 数据永远找不到,导入面板显示「未检测到」。macOS/Linux 行为不变。新增适配器专属测试(真实临时 SQLite 库覆盖三平台路径与三层还原)。", - "safetyNotes": "以只读方式打开各工具的本地会话数据库;会话写入走宿主 session 导入接口。熔炉使用宿主自身额度调用模型,插件不接触 API Key。fs.write 仅用于保存蒸馏结果,范围限定在工作区内的 AGENTS.md、*.md、docs/**、.agents/skills/**。不发起网络请求,无遥测。 自定义来源配置(工作区 docs/session-import-sources.json)只接受纯数据,插件永不执行其中的代码。", - "author": "muzimu217", - "main": "main.js", - "ui": { - "panel": "views/importer.html", - "width": 1040, - "height": 720, - "title": { - "en": "Session Import", - "zh-CN": "一体化会话导入" - } - }, - "contributes": { - "sessionSources": [ - { - "id": "zcode", - "label": { - "en": "ZCode", - "zh-CN": "ZCode" - } - }, - { - "id": "workbuddy", - "label": { - "en": "WorkBuddy", - "zh-CN": "WorkBuddy" - } - }, - { - "id": "claude-code", - "label": { - "en": "Claude Code", - "zh-CN": "Claude Code" - } - }, - { - "id": "codex", - "label": { - "en": "Codex", - "zh-CN": "Codex" - } - }, - { - "id": "opencode", - "label": { - "en": "OpenCode", - "zh-CN": "OpenCode" - } - }, - { - "id": "pi", - "label": { - "en": "Pi", - "zh-CN": "Pi" - } - } - ], - "views": [ - { - "id": "universal-session-import", - "title": { - "en": "Session Import", - "zh-CN": "一体化会话导入" - }, - "icon": "list-checks", - "entry": "views/importer.html", - "order": 45 - }, - { - "id": "session-forge", - "title": { - "en": "Session Forge", - "zh-CN": "会话熔炉" - }, - "icon": "sparkles", - "entry": "views/forge.html", - "order": 46 - } - ], - "commands": [ - { - "id": "session-import.open", - "title": "Session Import: Open Panel", - "keywords": [ - "import", - "导入", - "会话", - "zcode", - "workbuddy", - "claude", - "codex", - "opencode" - ], - "category": "Import" - }, - { - "id": "session-forge.open", - "title": "Session Forge: Distill Imported Sessions", - "keywords": [ - "forge", - "蒸馏", - "distill", - "规则", - "AGENTS.md", - "skill" - ], - "category": "Import" - } - ], - "settings": [ - { - "key": "forgeOutputPath", - "title": "蒸馏输出路径", - "description": "相对于工作区根目录。写入范围受插件声明的 fs.write 限制。", - "type": "string", - "default": "AGENTS.md", - "scope": "plugin" - }, - { - "key": "forgeThinkingLevel", - "title": "蒸馏推理强度", - "type": "select", - "default": "high", - "enum": [ - { - "label": "off", - "value": "off" - }, - { - "label": "low", - "value": "low" - }, - { - "label": "medium", - "value": "medium" - }, - { - "label": "high (recommended)", - "value": "high" - } - ], - "scope": "plugin" - } - ] - }, - "permissions": [ - "ui.panel", - "ui.view", - "session.read", - "session.read.own", - "session.import", - "project.create", - "agent.complete", - "models.list", - "fs.read", - "fs.write" - ], - "fs": { - "read": { - "root": "workspace", - "scope": [ - "AGENTS.md", - "*.md", - "docs/**", - ".agents/skills/**" - ] - }, - "write": { - "root": "workspace", - "scope": [ - "AGENTS.md", - "*.md", - "docs/**", - ".agents/skills/**" - ] - } - }, - "engines": { - "piDesktop": ">=0.14.3" - }, - "activationEvents": [ - "onCommand:session-import.open", - "onCommand:session-forge.open", - "onStartup" - ] -} \ No newline at end of file diff --git a/plugins/io.github.muzimu217.session-import/test/bus.test.mjs b/plugins/io.github.muzimu217.session-import/test/bus.test.mjs deleted file mode 100644 index 65906c2..0000000 --- a/plugins/io.github.muzimu217.session-import/test/bus.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/bus.js — typed event bus + recent-import snapshot. - * - * Run: - * node --test test/bus.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); -const bus = require(`${PLUGIN_DIR}/lib/bus.js`); - -describe("bus.EVENTS", () => { - test("freezes so consumers cannot mutate the protocol", () => { - const orig = bus.EVENTS.SESSION_IMPORTED; - assert.throws(() => { bus.EVENTS.SESSION_IMPORTED = "nope"; }, TypeError); - assert.strictEqual(bus.EVENTS.SESSION_IMPORTED, orig); - }); - - test("exposes all three event names used by main.js", () => { - assert.strictEqual(bus.EVENTS.SESSION_IMPORTED, "session.imported"); - assert.strictEqual(bus.EVENTS.DISTILLATION_SAVED, "distillation.saved"); - assert.strictEqual(bus.EVENTS.SCAN_FAILED, "scan.failed"); - }); -}); - -describe("bus.recordImport — recent snapshot", () => { - test("records at/count/source and updates the snapshot in place", () => { - const before = bus.recentImport.at; - bus.recordImport({ count: 42, source: "claude-code" }); - assert.strictEqual(bus.recentImport.count, 42); - assert.strictEqual(bus.recentImport.source, "claude-code"); - assert.ok(bus.recentImport.at instanceof Date); - assert.ok(bus.recentImport.at.getTime() >= (before?.getTime() ?? 0), - "at strictly non-decreasing"); - }); - - test("emits SESSION_IMPORTED with a copy of the snapshot", () => { - let captured = null; - bus.once(bus.EVENTS.SESSION_IMPORTED, (payload) => { captured = payload; }); - bus.recordImport({ count: 1, source: "zcode" }); - assert.ok(captured); - assert.strictEqual(captured.count, 1); - assert.strictEqual(captured.source, "zcode"); - assert.ok(captured.at instanceof Date); - }); - - test("handles missing source (legacy commit may not have one)", () => { - bus.recordImport({ count: 3 }); - assert.strictEqual(bus.recentImport.count, 3); - assert.strictEqual(bus.recentImport.source, null); - }); -}); - -describe("bus.recordDistillation / recordScanFailure", () => { - test("emits DISTILLATION_SAVED with at/path/bytes", () => { - let captured = null; - bus.once(bus.EVENTS.DISTILLATION_SAVED, (p) => { captured = p; }); - bus.recordDistillation({ path: "AGENTS.md", bytes: 4096 }); - assert.ok(captured); - assert.strictEqual(captured.path, "AGENTS.md"); - assert.strictEqual(captured.bytes, 4096); - assert.ok(captured.at instanceof Date); - }); - - test("emits SCAN_FAILED with structured payload (F-10 integration)", () => { - let captured = null; - bus.once(bus.EVENTS.SCAN_FAILED, (p) => { captured = p; }); - bus.recordScanFailure({ source: "zcode", code: "ENOENT", message: "no such file" }); - assert.ok(captured); - assert.strictEqual(captured.source, "zcode"); - assert.strictEqual(captured.code, "ENOENT"); - assert.match(captured.message, /no such file/); - assert.ok(captured.at instanceof Date); - }); -}); - -describe("listener management", () => { - test("on + off cleanly detaches", () => { - let count = 0; - const fn = () => { count += 1; }; - bus.on(bus.EVENTS.SESSION_IMPORTED, fn); - bus.recordImport({ count: 1, source: "zcode" }); - bus.off(bus.EVENTS.SESSION_IMPORTED, fn); - bus.recordImport({ count: 2, source: "zcode" }); - assert.strictEqual(count, 1); - }); - - test("many listeners can attach without throwing", () => { - const fns = []; - for (let i = 0; i < 30; i++) { - const f = () => {}; - bus.on(bus.EVENTS.SCAN_FAILED, f); - fns.push(f); - } - for (const f of fns) bus.off(bus.EVENTS.SCAN_FAILED, f); - assert.ok(true, "did not throw"); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/commit-path.test.mjs b/plugins/io.github.muzimu217.session-import/test/commit-path.test.mjs deleted file mode 100644 index 35d8322..0000000 --- a/plugins/io.github.muzimu217.session-import/test/commit-path.test.mjs +++ /dev/null @@ -1,210 +0,0 @@ -// End-to-end harness for the official-session-API commit path (no host needed). -import { createRequire } from "node:module"; -import assert from "node:assert"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); - -const importBatchCalls = []; -const stubAdapter = { - source: "stub", - label: "Stub", - scan: async () => [], - convert: async (item) => ({ - session: { - id: `import-stub-${item.externalId}`, - title: `标题 ${item.externalId}`, - projectPath: "/tmp/proj", - modelId: "m", - providerId: null, - mode: "agent", - // deliberately: updatedAt earlier than createdAt, message times out of order - createdAt: "2026-09-08T00:00:00.000Z", - updatedAt: "2026-09-07T23:00:00.000Z", - }, - messages: [ - { role: "user", content: "m1", createdAt: "2026-09-08T00:10:00.000Z" }, - { role: "assistant", content: "m2", createdAt: "2026-09-08T00:05:00.000Z" }, - { role: "assistant", content: "m3", createdAt: "2026-09-08T00:20:00.000Z" }, - { role: "user", content: "m4", createdAt: "garbage" }, - { role: "system", content: "dropped" }, - { - role: "tool", - content: "out", - createdAt: "2026-09-08T00:30:00.000Z", - toolName: "Read", - toolCallId: "c1", - toolStatus: "error", - toolArgs: { a: { b: { c: { d: { e: { f: { g: { h: { i: 1 } } } } } } } } }, - toolResult: "r", - }, - ], - }), -}; - -// Inject the stub registry before main.js can load the real one. -const registryPath = require.resolve(`${PLUGIN_DIR}/lib/registry.js`); -require.cache[registryPath] = { - id: registryPath, - filename: registryPath, - loaded: true, - exports: { - ADAPTERS: [stubAdapter], - getAdapter: (s) => (s === "stub" ? stubAdapter : null), - }, -}; - -globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {} }, - session: { - importBatch: async (input) => { - importBatchCalls.push(input); - const results = input.sessions.map((s) => ({ - externalId: s.externalId, - sessionId: `sid-${s.externalId}`, - status: "imported", - })); - return { results, imported: input.sessions.length, skipped: 0, failed: 0 }; - }, - }, -}; - -const main = require(`${PLUGIN_DIR}/main.js`); - -// 1. capabilities detection -const caps = await main.onPanelInvoke("import.capabilities"); -assert.strictEqual(caps.officialSessionApi, true, "official API detected"); - -// 2. commit 150 sessions -> must chunk into 100 + 50 -const items = Array.from({ length: 150 }, (_, i) => ({ - externalId: `e${i}`, - title: `T${i}`, -})); -const res = await main.onPanelInvoke("import.commit", { source: "stub", items }); -assert.strictEqual(res.imported, 150, "all imported"); -assert.strictEqual(importBatchCalls.length, 2, "two batches"); -assert.strictEqual(importBatchCalls[0].sessions.length, 100, "first batch 100"); -assert.strictEqual(importBatchCalls[1].sessions.length, 50, "second batch 50"); -assert.strictEqual(importBatchCalls[0].source, "stub", "source passed through"); -assert.strictEqual(importBatchCalls[0].mode, "skip", "skip mode"); - -// 3. contract conformance on the first session -const s = importBatchCalls[0].sessions[0]; - -// 3b. Default placement is "project": a source session that records a project -// path is bound to that project. The host's import_session runs ensure_project -// on the path, so carrying projectPath is enough to bind — the plugin sends no -// projectId (the host resolves/creates the project). Sessions with no projectPath -// fall back to the standalone SESSIONS list instead. Regresses the "checkbox is a -// no-op" bug and the standalone-import-never-shows-up bug. -assert.strictEqual(s.projectPath, "/tmp/proj", "project-bound: projectPath carried so host ensure_project binds it"); -assert.strictEqual(s.projectId, undefined, "project-bound: no host projectId sent (host resolves via path)"); - -assert.strictEqual(s.createdAt, "2026-09-08T00:00:00.000Z", "createdAt = min"); -assert.strictEqual(s.updatedAt, "2026-09-08T00:00:00.000Z", "updatedAt clamped up >= createdAt"); -assert.strictEqual(s.messages.length, 6 - 1, "system message dropped"); -const t = s.messages.map((m) => m.createdAt); -assert.deepStrictEqual( - t, - [ - "2026-09-08T00:10:00.000Z", - "2026-09-08T00:10:00.000Z", // out-of-order clamped up - "2026-09-08T00:20:00.000Z", - "2026-09-08T00:20:00.000Z", // invalid timestamp -> previous bound - "2026-09-08T00:30:00.000Z", - ], - "message timestamps monotonic non-decreasing", -); -const toolMsg = s.messages[4]; -assert.strictEqual(toolMsg.toolStatus, "error"); -assert.strictEqual(typeof toolMsg.toolArgs, "string", "deep JSON degraded to string"); -assert.ok(toolMsg.toolCallId === "c1" && toolMsg.toolName === "Read"); - -// 4. Escape-inflation guard. -// -// The host validates `JSON.stringify(field)` bytes, not raw string bytes. -// JSON escaping can add ~44% to a string of quotes/newlines/backslashes, so a -// payload truncated by *raw* byte count could still exceed the host's limit -// and get the whole batch rejected with "toolResult exceeds 256 KiB". -// Regression: every field must fit once re-serialized. -const MAX_CONTENT = 512 * 1024; -const MAX_TOOL = 256 * 1024; -const serializedBytes = (v) => { - const json = JSON.stringify(v); - return json === undefined ? Infinity : Buffer.byteLength(json, "utf8"); -}; - -const escapeHeavy = '{"a":"b\\nc"},\n'.repeat(40000); // ~560KB of escape-dense text -const huge = escapeHeavy + "x".repeat(600 * 1024); -assert.ok( - serializedBytes(huge) > MAX_TOOL, - "fixture must start above the toolResult limit", -); - -const stubAdapterHeavy = { - source: "heavy", - label: "Heavy", - scan: async () => [], - convert: async () => ({ - session: { - id: "import-heavy-1", - title: "heavy", - createdAt: "2026-09-08T00:00:00.000Z", - updatedAt: "2026-09-08T00:00:00.000Z", - }, - messages: [ - { - role: "assistant", - content: huge, // escape-dense content, over the 512 KiB content budget - createdAt: "2026-09-08T00:00:00.000Z", - }, - { - role: "tool", - content: "done", - createdAt: "2026-09-08T00:00:01.000Z", - toolName: "Bash", - toolCallId: "c-heavy", - toolStatus: "success", - toolArgs: huge, - toolResult: huge, - }, - ], - }), -}; -// Re-inject the registry so the heavy adapter is reachable, then re-require main. -const registryPath2 = require.resolve(`${PLUGIN_DIR}/lib/registry.js`); -require.cache[registryPath2].exports = { - ADAPTERS: [stubAdapter, stubAdapterHeavy], - getAdapter: (id) => (id === "stub" ? stubAdapter : id === "heavy" ? stubAdapterHeavy : null), -}; -delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; -const main2 = require(`${PLUGIN_DIR}/main.js`); - -importBatchCalls.length = 0; -await main2.onPanelInvoke("import.commit", { - source: "heavy", - items: [{ externalId: "heavy-1", title: "heavy" }], -}); -const heavyPayload = importBatchCalls[0]; -assert.ok(heavyPayload, "heavy batch captured"); -const heavySession = heavyPayload.sessions[0]; -for (const m of heavySession.messages) { - assert.ok( - serializedBytes(m.content) <= MAX_CONTENT, - `content must fit 512 KiB once serialized (got ${serializedBytes(m.content)})`, - ); - if (m.role !== "tool") continue; - for (const field of ["toolArgs", "toolResult"]) { - if (m[field] === undefined) continue; - assert.ok( - serializedBytes(m[field]) <= MAX_TOOL, - `${field} must fit 256 KiB once serialized (got ${serializedBytes(m[field])})`, - ); - } -} - -console.log("ALL COMMIT-PATH ASSERTIONS PASSED (150 sessions, 2 batches, contract guards verified)"); -console.log("ESCAPE-INFLATION GUARD VERIFIED (content/toolArgs/toolResult fit serialized limits)"); diff --git a/plugins/io.github.muzimu217.session-import/test/custom-sources.test.mjs b/plugins/io.github.muzimu217.session-import/test/custom-sources.test.mjs deleted file mode 100644 index b70f506..0000000 --- a/plugins/io.github.muzimu217.session-import/test/custom-sources.test.mjs +++ /dev/null @@ -1,350 +0,0 @@ -// @ts-check -/** - * Tests for lib/custom-sources.js — the security boundary of the - * extensibility layer. - * - * The plugin runs with full read access to the user's home directory, so a - * config file that could smuggle in behaviour would be arbitrary code - * execution. These tests pin that down: specs must be pure data, must not - * shadow built-ins, and must name a registered driver. - * - * Run: - * node --test test/custom-sources.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); -const cs = require(`${PLUGIN_DIR}/lib/custom-sources.js`); -const { makeDeclarativeSource } = require(`${PLUGIN_DIR}/lib/sources/declarative.js`); - -function workspace() { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "sicfg-")); - fs.mkdirSync(path.join(root, "docs"), { recursive: true }); - return root; -} - -function writeConfig(root, value) { - fs.writeFileSync( - path.join(root, cs.CONFIG_PATH), - typeof value === "string" ? value : JSON.stringify(value), - ); -} - -const validSpec = (over = {}) => ({ - id: "mytool", - label: "My Tool", - driver: "jsonl-transcript", - root: path.join(os.homedir(), ".mytool", "sessions"), - entry: { rolePath: "role", content: { path: "content" } }, - ...over, -}); - -describe("validateSpec", () => { - test("accepts a well-formed spec", () => { - const { ok, errors, spec } = cs.validateSpec(validSpec(), 0); - assert.equal(ok, true, errors.join("; ")); - assert.deepEqual(errors, []); - assert.equal(spec.id, "mytool"); - assert.ok(path.isAbsolute(spec.root), "root is resolved to an absolute path"); - }); - - test("rejects keys that would turn data into behaviour", () => { - for (const key of ["eval", "code", "require", "transform", "script", "__proto__"]) { - const { ok, errors } = cs.validateSpec({ ...validSpec(), [key]: "console.log(1)" }, 0); - assert.equal(ok, false, `spec with "${key}" must be rejected`); - assert.ok( - errors.some((e) => e.toLowerCase().includes("forbidden")), - `expected a "forbidden key" error for ${key}, got: ${errors.join("; ")}`, - ); - } - }); - - test("rejects a nested forbidden key (deep in the spec)", () => { - const spec = validSpec({ entry: { rolePath: "role", content: { path: "content" }, eval: "x" } }); - const { ok, errors } = cs.validateSpec(spec, 0); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("entry.eval")), errors.join("; ")); - }); - - test("rejects non-data values (functions)", () => { - const spec = validSpec({ entry: { rolePath: "role", content: { path: "content" }, helper: () => 1 } }); - const { ok, errors } = cs.validateSpec(spec, 0); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("unsupported value type")), errors.join("; ")); - }); - - test("rejects ids that shadow a built-in source", () => { - for (const id of cs.BUILTIN_IDS) { - const { ok, errors } = cs.validateSpec(validSpec({ id }), 0); - assert.equal(ok, false, `"${id}" must not be overridable`); - assert.ok(errors.some((e) => e.includes("built-in")), errors.join("; ")); - } - }); - - test("rejects malformed ids", () => { - for (const id of ["", "bad id!", "9leading", "a".repeat(80)]) { - const { ok } = cs.validateSpec(validSpec({ id }), 0); - assert.equal(ok, false, `id "${id}" must be rejected`); - } - }); - - test("rejects unknown drivers", () => { - const { ok, errors } = cs.validateSpec(validSpec({ driver: "totally-custom" }), 0); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes("must be one of")), errors.join("; ")); - }); - test("rejects absurdly broad data roots", () => { - for (const root of ["/", os.homedir(), "C:\\", "C:/"]) { - const { ok, errors } = cs.validateSpec(validSpec({ root }), 0); - assert.equal(ok, false, `root "${root}" must be rejected`); - assert.ok(errors.some((e) => e.includes("refusing")), errors.join("; ")); - } - }); - - test("rejects empty or wildcard extensions", () => { - for (const extension of ["", "*", ".", ".jsonl.exe"]) { - const { ok, errors } = cs.validateSpec(validSpec({ extension }), 0); - assert.equal(ok, false, `extension "${extension}" must be rejected`); - } - }); - - test("expands ~ in paths", () => { - const { ok, spec } = cs.validateSpec(validSpec({ root: "~/.mytool/sessions" }), 0); - assert.equal(ok, true); - assert.ok(spec.root.startsWith(os.homedir())); - assert.ok(!spec.root.includes("~")); - }); - - test("requires a db path for the sqlite driver", () => { - const { ok, errors } = cs.validateSpec( - { id: "sqltool", label: "SQL", driver: "sqlite-session" }, - 0, - ); - assert.equal(ok, false); - assert.ok(errors.some((e) => e.includes(".db")), errors.join("; ")); - }); -}); - -describe("loadCustomSources", () => { - test("no config file -> no specs, no errors", async () => { - const root = workspace(); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.deepEqual(res.specs, []); - assert.deepEqual(res.errors, []); - }); - - test("loads a valid config (array form)", async () => { - const root = workspace(); - writeConfig(root, [validSpec()]); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.equal(res.specs.length, 1); - assert.equal(res.specs[0].id, "mytool"); - assert.deepEqual(res.errors, []); - }); - - test("loads a valid config (object form with sources[])", async () => { - const root = workspace(); - writeConfig(root, { sources: [validSpec({ id: "other" })] }); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.equal(res.specs.length, 1); - assert.equal(res.specs[0].id, "other"); - }); - - test("malformed JSON is reported, not thrown", async () => { - const root = workspace(); - writeConfig(root, "{ not json"); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.deepEqual(res.specs, []); - assert.ok(res.errors.some((e) => e.includes("not valid JSON"))); - }); - - test("invalid specs are skipped and reported", async () => { - const root = workspace(); - writeConfig(root, [validSpec(), validSpec({ id: "opencode" }), validSpec({ driver: "nope" })]); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.equal(res.specs.length, 1); // only the valid one - assert.ok(res.errors.length >= 2); - }); - - test("an oversized config is refused", async () => { - const root = workspace(); - writeConfig(root, `[${JSON.stringify(validSpec())},${" ".repeat(600 * 1024)}]`); - const res = await cs.loadCustomSources({ workspaceRoot: root }); - assert.deepEqual(res.specs, []); - assert.ok(res.errors.some((e) => e.includes("too large"))); - }); - - test("supports an injected reader (host fs bridge)", async () => { - let asked = null; - const res = await cs.loadCustomSources({ - readText: async (rel) => { - asked = rel; - return JSON.stringify([validSpec({ id: "viareader" })]); - }, - }); - assert.equal(asked, cs.CONFIG_PATH); - assert.equal(res.specs.length, 1); - assert.equal(res.specs[0].id, "viareader"); - }); - - test("a missing file via the injected reader yields no sources", async () => { - const res = await cs.loadCustomSources({ - readText: async () => { - throw new Error("ENOENT"); - }, - }); - assert.deepEqual(res.specs, []); - assert.deepEqual(res.errors, []); - }); -}); - -describe("end-to-end: config -> working adapter", () => { - test("a source declared in config actually scans real files", async () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "sie2e-")); - const dataDir = path.join(home, ".mytool", "sessions"); - fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync( - path.join(dataDir, "s1.jsonl"), - JSON.stringify({ role: "user", content: "hello from custom source" }) + "\n" + - JSON.stringify({ role: "assistant", content: "hi" }) + "\n", - ); - - const wsRoot = workspace(); - writeConfig(wsRoot, [ - { - id: "mytool", - label: "My Tool", - driver: "jsonl-transcript", - root: dataDir, - entry: { rolePath: "role", content: { path: "content" } }, - }, - ]); - - const { specs, errors } = await cs.loadCustomSources({ workspaceRoot: wsRoot }); - assert.deepEqual(errors, []); - assert.equal(specs.length, 1); - - const adapter = makeDeclarativeSource(specs[0]); - const summaries = await adapter.scan(); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].messageCount, 2); - const { messages } = await adapter.convert(summaries[0]); - assert.equal(messages[0].content, "hello from custom source"); - }); -}); - -describe("end-to-end: config -> registry -> pipeline", () => { - test("a custom source becomes reachable through allAdapters/getAdapter", async () => { - const registry = require(`${PLUGIN_DIR}/lib/registry.js`); - const home = fs.mkdtempSync(path.join(os.tmpdir(), "sireg-")); - const dataDir = path.join(home, ".mytool", "sessions"); - fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync( - path.join(dataDir, "s1.jsonl"), - JSON.stringify({ role: "user", content: "registry integration" }) + "\n", - ); - - const wsRoot = workspace(); - writeConfig(wsRoot, [ - { - id: "mytool", - label: "My Tool", - driver: "jsonl-transcript", - root: dataDir, - entry: { rolePath: "role", content: { path: "content" } }, - }, - ]); - - const dynamic = await registry.refreshDynamicSources({ workspaceRoot: wsRoot }); - assert.equal(dynamic.length, 1); - assert.equal(dynamic[0].source, "mytool"); - - // Built-ins stay at 6 (ADR 0008) while the pipeline sees 7. - assert.equal(registry.ADAPTERS.length, 6); - assert.equal(registry.allAdapters().length, 7); - assert.ok(registry.getAdapter("mytool"), "custom source is resolvable"); - - // ...and it really scans through the same entry point main.js uses. - const summaries = await registry.getAdapter("mytool").scan(); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].messageCount, 1); - }); - - test("built-ins always win when a spec tries to shadow one", async () => { - const registry = require(`${PLUGIN_DIR}/lib/registry.js`); - const wsRoot = workspace(); - // "opencode" is a built-in; validation must refuse to register it. - writeConfig(wsRoot, [ - { id: "opencode", label: "Fake", driver: "jsonl-transcript", root: "/tmp" }, - ]); - const dynamic = await registry.refreshDynamicSources({ workspaceRoot: wsRoot }); - assert.equal(dynamic.length, 0); - const report = registry.getDynamicLoadReport(); - assert.ok(report.errors.some((e) => e.includes("built-in"))); - assert.notEqual(registry.getAdapter("opencode").label, "Fake"); - }); -}); - -describe("custom sources: real-world spec shapes stay valid data", () => { - test("toolCall / textOps / follow / unwrapPath specs are accepted", () => { - // These are the shapes the built-in Claude/WorkBuddy/Codex adapters need. - // None of them may be rejected by the declarative-only denylist. - const spec = { - id: "claude-like", - label: "Claude-like", - driver: "jsonl-transcript", - root: "~/.claude/projects", - maxDepth: 2, - session: { - idFrom: { first: [{ path: "payload.id" }, { path: "id" }] }, - idFromEntry: { path: "type", in: ["session_meta"] }, - titleFrom: "firstUser", - }, - entry: { - unwrapPath: "payload", - match: { path: "type", in: ["user", "assistant"] }, - rolePath: "message.role", - content: { blocks: { path: "message.content", typeField: "type", types: ["text"], textField: "text" } }, - tsPath: "timestamp", - drop: { startsWith: ["<"], roles: ["user"] }, - textOps: [ - { op: "stripXmlBlocks", tags: ["system-reminder"], roles: ["user"] }, - { op: "extractXmlTag", tag: "user_query", roles: ["user"] }, - ], - toolCall: { - callBlocks: { path: "message.content", typeField: "type", type: "tool_use", idPath: "id", namePath: "name", argsPath: "input", roles: ["assistant"] }, - resultBlocks: { path: "message.content", typeField: "type", type: "tool_result", idPath: "tool_use_id", resultPath: "content", statusPath: "is_error", roles: ["user"] }, - emitUnpaired: true, - }, - }, - }; - const { ok, errors } = cs.validateSpec(spec, 0); - assert.deepEqual(errors, []); - assert.equal(ok, true); - }); - - test("a follow rule cannot widen the read scope beyond the data root", () => { - // follow is data, so it passes validation; the containment check lives in - // the mapper (see entry-map tests) and must stay there. - const { ok } = cs.validateSpec( - { - id: "with-follow", - label: "Follow", - driver: "jsonl-transcript", - root: "~/.mytool/sessions", - entry: { toolCall: { result: { follow: { marker: "Full output saved to:", maxBytes: 1024 } } } }, - }, - 0, - ); - assert.equal(ok, true); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/drivers.test.mjs b/plugins/io.github.muzimu217.session-import/test/drivers.test.mjs deleted file mode 100644 index f4f5f6f..0000000 --- a/plugins/io.github.muzimu217.session-import/test/drivers.test.mjs +++ /dev/null @@ -1,481 +0,0 @@ -// @ts-check -/** - * Tests for the extensibility layer's format drivers. - * - * Each driver is exercised against a real on-disk fixture (a JSONL transcript, - * a JSON session tree, and a SQLite database shaped like OpenCode/ZCode), so - * these cover the actual mapping code paths rather than mocks. - * - * Run: - * node --test test/drivers.test.mjs - */ -"use strict"; - -import { test, describe, before, after } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); - -const jsonl = require(`${PLUGIN_DIR}/lib/drivers/jsonl-transcript.js`); -const jsonTree = require(`${PLUGIN_DIR}/lib/drivers/json-tree.js`); -const sqliteSession = require(`${PLUGIN_DIR}/lib/drivers/sqlite-session.js`); -const drivers = require(`${PLUGIN_DIR}/lib/drivers/index.js`); -const { makeDeclarativeSource } = require(`${PLUGIN_DIR}/lib/sources/declarative.js`); - -let tmpRoot; - -before(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sidrv-")); -}); -after(() => { - try { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } catch { - /* best effort */ - } -}); - -function makeDir(name) { - const dir = path.join(tmpRoot, name); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -/* ------------------------------------------------------------------ * - * jsonl-transcript - * ------------------------------------------------------------------ */ - -describe("driver: jsonl-transcript", () => { - // Each call gets its own directory: scans are recursive, so sharing one - // would let later tests see earlier fixtures. - let seq = 0; - const dir = () => makeDir(`jsonl-${++seq}`); - - test("scans a transcript and derives title/count/project", async () => { - const root = dir(); - const file = path.join(root, "proj-a", "sess-1.jsonl"); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync( - file, - [ - JSON.stringify({ - role: "user", - message: { content: [{ type: "text", text: "fix the build" }] }, - timestamp: 1700000000000, - }), - JSON.stringify({ - role: "assistant", - message: { content: [{ type: "text", text: "on it" }] }, - timestamp: 1700000001000, - }), - ].join("\n") + "\n", - ); - - const spec = { - driver: "jsonl-transcript", - root, - extension: ".jsonl", - session: { idFrom: "filename", titleFrom: "firstUser", projectFrom: "parentDir" }, - entry: { - rolePath: "role", - content: { - blocks: { path: "message.content", typeField: "type", types: ["text"], textField: "text" }, - }, - tsPath: "timestamp", - }, - }; - - const summaries = await jsonl.scan(spec, "mytest"); - assert.equal(summaries.length, 1); - const s = summaries[0]; - assert.equal(s.source, "mytest"); - assert.equal(s.externalId, "sess-1"); - assert.equal(s.messageCount, 2); - assert.equal(s.title, "fix the build"); // titleFrom: firstUser - assert.ok(s.projectName); // parentDir derived - assert.equal(s.filePath, file); - assert.ok(s.createdAt); - }); - - test("maps tool entries into tool messages", async () => { - const root = dir(); - const file = path.join(root, "sess-2.jsonl"); - fs.writeFileSync( - file, - [ - JSON.stringify({ role: "user", content: "run ls", timestamp: 1700000000000 }), - JSON.stringify({ - type: "tool_use", - name: "bash", - input: { cmd: "ls" }, - output: "file.txt", - status: "completed", - timestamp: 1700000001000, - }), - JSON.stringify({ role: "assistant", content: "done", timestamp: 1700000002000 }), - ].join("\n") + "\n", - ); - - const spec = { - driver: "jsonl-transcript", - root, - entry: { - rolePath: "role", - content: { path: "content" }, - tsPath: "timestamp", - tool: { - typePath: "type", - toolTypes: ["tool_use"], - namePath: "name", - argsPath: "input", - resultPath: "output", - statusPath: "status", - }, - }, - }; - - const summaries = await jsonl.scan(spec, "mytest"); - assert.equal(summaries.length, 1); - const { messages } = await jsonl.convert(spec, summaries[0]); - assert.equal(messages.length, 3); - assert.deepEqual( - messages.map((m) => m.role), - ["user", "tool", "assistant"], - ); - const tool = messages[1]; - assert.equal(tool.toolName, "bash"); - assert.equal(tool.toolStatus, "success"); - assert.equal(tool.content, "file.txt"); - assert.deepEqual(tool.toolArgs, { cmd: "ls" }); - }); - - test("idFromEntry keeps a per-item id from shadowing the session id", async () => { - const root = dir(); - // Codex: every response_item carries its own id, so a naive "first id wins" - // would name the session after an item instead of the session_meta line. - fs.writeFileSync( - path.join(root, "sess-3.jsonl"), - [ - JSON.stringify({ timestamp: 1, type: "response_item", payload: { id: "item-1", type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] } }), - JSON.stringify({ timestamp: 2, type: "session_meta", payload: { id: "sess-real", cwd: "/tmp" } }), - ].join("\n") + "\n", - ); - - const spec = { - driver: "jsonl-transcript", - root, - entry: { - unwrapPath: "payload", - rolePath: "role", - content: { blocks: { path: "content", typeField: "type", types: ["input_text"], textField: "text" } }, - tsPath: "timestamp", - }, - session: { - idFrom: { first: [{ path: "payload.id" }, { path: "id" }] }, - idFromEntry: { path: "type", in: ["session_meta"] }, - titleFrom: "firstUser", - }, - }; - - const summaries = await jsonl.scan(spec, "mytest"); - assert.equal(summaries[0].externalId, "sess-real"); - }); - - test("ignores malformed lines and returns [] for a missing root", async () => { - const root = dir(); - fs.writeFileSync(path.join(root, "bad.jsonl"), "{not json\n\n" + JSON.stringify({ role: "user", content: "ok" }) + "\n"); - const spec = { - driver: "jsonl-transcript", - root, - entry: { rolePath: "role", content: { path: "content" } }, - }; - const summaries = await jsonl.scan(spec, "mytest"); - assert.equal(summaries.length, 1); // bad line skipped, good line kept - assert.equal(summaries[0].messageCount, 1); - - const missing = await jsonl.scan({ ...spec, root: path.join(root, "nope") }, "mytest"); - assert.deepEqual(missing, []); - }); -}); - -/* ------------------------------------------------------------------ * - * json-tree - * ------------------------------------------------------------------ */ - -describe("driver: json-tree", () => { - test("reads one JSON file per session", async () => { - const root = makeDir("tree"); - fs.writeFileSync( - path.join(root, "sess.json"), - JSON.stringify({ - id: "t1", - title: "Tree Session", - createdAt: "2026-01-01T00:00:00Z", - directory: "/tmp/some-project", - messages: [ - { role: "user", content: "hello" }, - { role: "assistant", content: "hi" }, - ], - }), - ); - - const spec = { - driver: "json-tree", - root, - extension: ".json", - session: { idPath: "id", titlePath: "title", tsPath: "createdAt", pathPath: "directory", messagesPath: "messages" }, - message: { rolePath: "role", content: { path: "content" } }, - }; - - const summaries = await jsonTree.scan(spec, "mytree"); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].externalId, "t1"); - assert.equal(summaries[0].title, "Tree Session"); - assert.equal(summaries[0].messageCount, 2); - - const { session, messages } = await jsonTree.convert(spec, summaries[0]); - assert.ok(session); - assert.equal(messages.length, 2); - assert.equal(messages[0].content, "hello"); - }); -}); - -/* ------------------------------------------------------------------ * - * sqlite-session - * ------------------------------------------------------------------ */ - -describe("driver: sqlite-session", () => { - const specFor = (db) => ({ - driver: "sqlite-session", - db, - session: { table: "session", idCol: "id", titleCol: "title", pathCol: "directory", createdCol: "time_created", updatedCol: "time_updated" }, - message: { table: "message", idCol: "id", sessionIdCol: "session_id", createdCol: "time_created", dataCol: "data", rolePath: "role", tsPath: "time.created" }, - part: { - table: "part", - messageIdCol: "message_id", - sessionIdCol: "session_id", - createdCol: "time_created", - dataCol: "data", - textTypes: ["text"], - toolType: "tool", - toolNamePath: "tool", - argsPath: "state.input", - resultPath: "state.output", - statusPath: "state.status", - }, - }); - - function buildDb(dbPath) { - const { DatabaseSync } = require("node:sqlite"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER); - CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT); - `); - db.prepare("INSERT INTO session VALUES (?,?,?,?,?)").run("s1", "/tmp/sqlite-proj", "SQLite Session", 1700000000000, 1700000002000); - db.prepare("INSERT INTO message VALUES (?,?,?,?)").run( - "m1", "s1", 1700000000000, - JSON.stringify({ role: "user", time: { created: 1700000000000 } }), - ); - db.prepare("INSERT INTO message VALUES (?,?,?,?)").run( - "m2", "s1", 1700000001000, - JSON.stringify({ role: "assistant", time: { created: 1700000001000 }, modelID: "test-model" }), - ); - db.prepare("INSERT INTO part VALUES (?,?,?,?,?)").run( - "p1", "m1", "s1", 1, JSON.stringify({ type: "text", text: "user asks" }), - ); - db.prepare("INSERT INTO part VALUES (?,?,?,?,?)").run( - "p2", "m2", "s1", 2, JSON.stringify({ type: "tool", tool: "bash", state: { status: "completed", input: { cmd: "ls" }, output: "file.txt" } }), - ); - db.prepare("INSERT INTO part VALUES (?,?,?,?,?)").run( - "p3", "m2", "s1", 3, JSON.stringify({ type: "text", text: "all done" }), - ); - db.close(); - } - - test("scans sessions with message counts", async () => { - const dir = makeDir("sqlite"); - const dbPath = path.join(dir, "test.db"); - buildDb(dbPath); - - const summaries = await sqliteSession.scan(specFor(dbPath), "mysqlite"); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].externalId, "s1"); - assert.equal(summaries[0].title, "SQLite Session"); - assert.equal(summaries[0].messageCount, 2); - }); - - test("rebuilds messages: text flush, tool payload, assistant tail", async () => { - const dir = makeDir("sqlite2"); - const dbPath = path.join(dir, "test.db"); - buildDb(dbPath); - - const summaries = await sqliteSession.scan(specFor(dbPath), "mysqlite"); - const { session, messages } = await sqliteSession.convert(specFor(dbPath), summaries[0]); - assert.deepEqual( - messages.map((m) => m.role), - ["user", "tool", "assistant"], - ); - assert.equal(messages[0].content, "user asks"); - assert.equal(messages[1].toolName, "bash"); - assert.equal(messages[1].toolStatus, "success"); - assert.equal(messages[1].content, "file.txt"); - assert.equal(messages[2].content, "all done"); - assert.equal(session.modelId, "test-model"); - }); - - test("orders by the app's own sequence column when it exists", async () => { - // ZCode writes a monotonic `sequence`; OpenCode has no such column. Rows - // here are inserted with deliberately misleading timestamps so only the - // sequence column can produce the right order. - const dir = makeDir("sqlite4"); - const dbPath = path.join(dir, "seq.db"); - const { DatabaseSync } = require("node:sqlite"); - const db = new DatabaseSync(dbPath); - db.exec(` - CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT, title TEXT, time_created INTEGER, time_updated INTEGER); - CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT, sequence INTEGER); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT, sequence INTEGER); - `); - db.prepare("INSERT INTO session VALUES (?,?,?,?,?)").run("s1", "/tmp/seq", "Seq", 1, 2); - db.prepare("INSERT INTO message VALUES (?,?,?,?,?)").run("m2", "s1", 500, JSON.stringify({ role: "assistant", time: { created: 500 } }), 2); - db.prepare("INSERT INTO message VALUES (?,?,?,?,?)").run("m1", "s1", 900, JSON.stringify({ role: "user", time: { created: 900 } }), 1); - db.prepare("INSERT INTO part VALUES (?,?,?,?,?,?)").run("p1", "m1", "s1", 1, JSON.stringify({ type: "text", text: "first by sequence" }), 1); - db.prepare("INSERT INTO part VALUES (?,?,?,?,?,?)").run("p2", "m2", "s1", 2, JSON.stringify({ type: "text", text: "second by sequence" }), 2); - db.close(); - - const summaries = await sqliteSession.scan(specFor(dbPath), "mysqlite"); - const { messages } = await sqliteSession.convert(specFor(dbPath), summaries[0]); - assert.deepEqual( - messages.map((m) => m.content), - ["first by sequence", "second by sequence"], - "sequence wins over the out-of-order time_created", - ); - }); - - test("returns [] when the database is absent", async () => { - const dir = makeDir("sqlite3"); - const summaries = await sqliteSession.scan(specFor(path.join(dir, "missing.db")), "mysqlite"); - assert.deepEqual(summaries, []); - }); -}); - -/* ------------------------------------------------------------------ * - * driver registry + declarative adapter - * ------------------------------------------------------------------ */ - -describe("extensibility: registry + declarative source", () => { - test("registers exactly the three shipped drivers", () => { - assert.deepEqual(drivers.driverNames().sort(), ["json-tree", "jsonl-transcript", "sqlite-session"]); - assert.ok(drivers.getDriver("jsonl-transcript")); - assert.equal(drivers.getDriver("does-not-exist"), null); - }); - - test("makeDeclarativeSource builds an adapter with the built-in contract", async () => { - const root = makeDir("decl"); - fs.writeFileSync( - path.join(root, "a.jsonl"), - JSON.stringify({ role: "user", content: "hi" }) + "\n" + - JSON.stringify({ role: "assistant", content: "yo" }) + "\n", - ); - - const adapter = makeDeclarativeSource({ - id: "mycustom", - label: "My Custom Tool", - driver: "jsonl-transcript", - root, - entry: { rolePath: "role", content: { path: "content" } }, - }); - - assert.equal(adapter.source, "mycustom"); - assert.equal(adapter.label, "My Custom Tool"); - assert.equal(adapter.custom, true); - assert.equal(typeof adapter.scan, "function"); - assert.equal(typeof adapter.convert, "function"); - - const summaries = await adapter.scan(); - assert.equal(summaries.length, 1); - const { session, messages } = await adapter.convert(summaries[0]); - assert.ok(session); - assert.equal(messages.length, 2); - assert.ok(session.id.startsWith("import-mycustom-")); - }); - - test("makeDeclarativeSource rejects an unknown driver", () => { - assert.throws(() => makeDeclarativeSource({ id: "x", label: "X", driver: "nope" }), /unknown driver/); - }); - - test("a broken spec degrades to empty results instead of throwing", async () => { - const adapter = makeDeclarativeSource({ - id: "broken", - label: "Broken", - driver: "jsonl-transcript", - root: "/nonexistent/path/for/testing", - entry: {}, - }); - assert.deepEqual(await adapter.scan(), []); - const out = await adapter.convert({ source: "broken", externalId: "z", filePath: "/nope" }); - assert.equal(out.session, null); - }); -}); - -describe("extract: content rules against real-world shapes", () => { - const { extractText } = require(`${PLUGIN_DIR}/lib/drivers/extract.js`); - const rule = { - blocks: { path: "message.content", typeField: "type", types: ["text"], textField: "text" }, - }; - - test("blocks rule returns a plain string when content is not an array", () => { - assert.equal(extractText(rule, { message: { content: "plain text" } }), "plain text"); - }); - - test("blocks rule never dumps raw JSON when no text block matches", () => { - // Real Claude entries carry thinking/tool_use-only content arrays. - assert.equal(extractText(rule, { message: { content: [{ type: "thinking", thinking: "x" }] } }), ""); - assert.equal(extractText(rule, { message: { content: [{ type: "tool_use", name: "Read" }] } }), ""); - }); - - test("blocks rule joins only the matching text blocks", () => { - const entry = { - message: { - content: [ - { type: "thinking", thinking: "ignored" }, - { type: "text", text: "hello" }, - { type: "text", text: "world" }, - ], - }, - }; - assert.equal(extractText(rule, entry), "hello\nworld"); - }); -}); - -describe("fsutil: maxDepth keeps scans out of nested non-session dirs", () => { - const { listFiles } = require(`${PLUGIN_DIR}/lib/drivers/fsutil.js`); - - test("maxDepth 2 collects // and nothing deeper", async () => { - const root = makeDir("depth"); - fs.mkdirSync(path.join(root, "proj", ".timelines", "deep"), { recursive: true }); - fs.writeFileSync(path.join(root, "proj", "session.jsonl"), "x"); - fs.writeFileSync(path.join(root, "proj", ".timelines", "deep", "messages.jsonl"), "y"); - - const shallow = await listFiles(root, { extension: ".jsonl", maxDepth: 2 }); - assert.deepEqual(shallow.map((f) => path.relative(root, f)), [path.join("proj", "session.jsonl")]); - - const deep = await listFiles(root, { extension: ".jsonl" }); - assert.equal(deep.length, 2, "unbounded recursion does reach the nested file"); - }); - - test("empty extension matches no files", async () => { - const root = makeDir("empty-ext"); - fs.writeFileSync(path.join(root, "session.jsonl"), "x"); - const none = await listFiles(root, { extension: "" }); - assert.deepEqual(none, []); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/entry-map.test.mjs b/plugins/io.github.muzimu217.session-import/test/entry-map.test.mjs deleted file mode 100644 index dc74943..0000000 --- a/plugins/io.github.muzimu217.session-import/test/entry-map.test.mjs +++ /dev/null @@ -1,377 +0,0 @@ -// @ts-check -/** - * Tests for the shared entry -> message mapper. - * - * These cover the three tool shapes real agents write, because that is where - * a declarative spec has to stay faithful to the hand-written adapters: - * - entry-level two-phase events (WorkBuddy function_call -> *_result) - * - block-level two-phase events (Claude tool_use -> tool_result) - * - self-contained tool entries - * Plus the envelope/drop/text-op/follow rules those adapters need. - * - * Run: - * node --test test/entry-map.test.mjs - */ -"use strict"; - -import { test, describe, before, after } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); - -const { mapEntries } = require(`${PLUGIN_DIR}/lib/drivers/entry-map.js`); -const { applyTextOps } = require(`${PLUGIN_DIR}/lib/drivers/extract.js`); - -let tmpRoot; -before(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simap-")); -}); -after(() => { - try { - fs.rmSync(tmpRoot, { recursive: true, force: true }); - } catch { - /* best effort */ - } -}); - -const roles = (msgs) => msgs.map((m) => m.role); - -/* ------------------------------------------------------------------ * - * shape 2: entry-level two-phase tool events (WorkBuddy) - * ------------------------------------------------------------------ */ - -describe("entry-map: entry-level toolCall pairing", () => { - const spec = { - match: { path: "type", in: ["message", "function_call", "function_call_result"] }, - rolePath: "role", - content: { path: "content" }, - tsPath: "timestamp", - toolCall: { - call: { - typePath: "type", - types: ["function_call"], - idPath: "callId", - namePath: "name", - argsPath: "arguments", - argsJson: true, - }, - result: { - typePath: "type", - types: ["function_call_result"], - idPath: "callId", - resultPath: "output", - statusPath: "status", - }, - }, - }; - - test("pairs a call with its later result and emits at result time", async () => { - const messages = await mapEntries(spec, [ - { type: "message", role: "user", content: "run it", timestamp: 1 }, - { type: "function_call", callId: "c1", name: "bash", arguments: '{"cmd":"ls"}', timestamp: 2 }, - { type: "function_call_result", callId: "c1", output: "file.txt", status: "completed", timestamp: 3 }, - { type: "message", role: "assistant", content: "done", timestamp: 4 }, - ]); - - assert.deepEqual(roles(messages), ["user", "tool", "assistant"]); - const tool = messages[1]; - assert.equal(tool.toolName, "bash"); - assert.equal(tool.content, "file.txt"); - assert.equal(tool.toolResult, "file.txt"); - assert.equal(tool.toolStatus, "success"); - assert.equal(tool.toolCallId, "c1"); - assert.deepEqual(tool.toolArgs, { cmd: "ls" }, "arguments string is parsed as JSON"); - }); - - test("a result object with .text is unwrapped; a failure maps to error", async () => { - const messages = await mapEntries(spec, [ - { type: "function_call", callId: "c2", name: "bash", timestamp: 1 }, - { type: "function_call_result", callId: "c2", output: { text: "boom" }, status: "error", timestamp: 2 }, - ]); - assert.equal(messages.length, 1); - assert.equal(messages[0].content, "boom"); - assert.equal(messages[0].toolStatus, "error"); - }); - - test("resultFormat json keeps a structured payload verbatim", async () => { - // codex.js stores whatever the tool returned: a string as-is, anything - // else JSON-stringified. It never scans the payload for text blocks. - const rawSpec = { - ...spec, - toolCall: { - ...spec.toolCall, - result: { ...spec.toolCall.result, resultFormat: "json" }, - }, - }; - const payload = [{ type: "input_text", text: "Wall time: 1s" }]; - const messages = await mapEntries(rawSpec, [ - { type: "function_call", callId: "c3", name: "bash", timestamp: 1 }, - { type: "function_call_result", callId: "c3", output: payload, timestamp: 2 }, - ]); - assert.equal(messages[0].content, JSON.stringify(payload)); - - // ...while the default still prefers the readable text - const textMessages = await mapEntries(spec, [ - { type: "function_call", callId: "c4", name: "bash", timestamp: 1 }, - { type: "function_call_result", callId: "c4", output: payload, timestamp: 2 }, - ]); - assert.equal(textMessages[0].content, "Wall time: 1s"); - }); - - test("an unpaired call emits nothing by default, and a running call with emitUnpaired", async () => { - const entries = [{ type: "function_call", callId: "lonely", name: "bash", timestamp: 1 }]; - assert.equal((await mapEntries(spec, entries)).length, 0); - - const withUnpaired = await mapEntries({ ...spec, toolCall: { ...spec.toolCall, emitUnpaired: true } }, entries); - assert.equal(withUnpaired.length, 1); - assert.equal(withUnpaired[0].toolStatus, "running"); - assert.equal(withUnpaired[0].toolName, "bash"); - }); -}); - -/* ------------------------------------------------------------------ * - * shape 3: block-level two-phase tool events (Claude Code) - * ------------------------------------------------------------------ */ - -describe("entry-map: block-level toolCall pairing", () => { - const spec = { - rolePath: "message.role", - content: { - blocks: { path: "message.content", typeField: "type", types: ["text"], textField: "text" }, - }, - tsPath: "timestamp", - drop: { startsWith: ["<"], roles: ["user"] }, - toolCall: { - callBlocks: { - path: "message.content", - typeField: "type", - type: "tool_use", - idPath: "id", - namePath: "name", - argsPath: "input", - roles: ["assistant"], - }, - resultBlocks: { - path: "message.content", - typeField: "type", - type: "tool_result", - idPath: "tool_use_id", - resultPath: "content", - statusPath: "is_error", - roles: ["user"], - }, - }, - }; - - test("tool_use blocks are answered by the later tool_result block", async () => { - const messages = await mapEntries(spec, [ - { type: "user", message: { role: "user", content: [{ type: "text", text: "read a" }] }, timestamp: 1 }, - { - type: "assistant", - message: { - role: "assistant", - content: [ - { type: "text", text: "sure" }, - { type: "tool_use", id: "t1", name: "Read", input: { file_path: "a" } }, - ], - }, - timestamp: 2, - }, - { - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "t1", content: "FILE BODY" }], - }, - timestamp: 3, - }, - { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "here" }] }, timestamp: 4 }, - ]); - - assert.deepEqual(roles(messages), ["user", "assistant", "tool", "assistant"]); - const tool = messages[2]; - assert.equal(tool.toolName, "Read"); - assert.equal(tool.content, "FILE BODY"); - assert.equal(tool.toolStatus, "success"); - assert.equal(tool.toolCallId, "t1"); - assert.deepEqual(tool.toolArgs, { file_path: "a" }); - }); - - test("an entry carrying tool_result blocks suppresses its own text", async () => { - const messages = await mapEntries(spec, [ - { type: "function_call", callId: "x", timestamp: 0 }, - { - type: "assistant", - message: { role: "assistant", content: [{ type: "tool_use", id: "t9", name: "Bash", input: {} }] }, - timestamp: 1, - }, - { - type: "user", - message: { - role: "user", - content: [ - { type: "text", text: "this text is not part of the conversation" }, - { type: "tool_result", tool_use_id: "t9", content: "out" }, - ], - }, - timestamp: 2, - }, - ]); - assert.deepEqual(roles(messages), ["tool"]); - }); - - test("is_error marks the tool message as failed", async () => { - const messages = await mapEntries(spec, [ - { type: "assistant", message: { role: "assistant", content: [{ type: "tool_use", id: "t2", name: "Bash", input: {} }] }, timestamp: 1 }, - { type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t2", content: "nope", is_error: true }] }, timestamp: 2 }, - ]); - assert.equal(messages[0].toolStatus, "error"); - }); -}); - -/* ------------------------------------------------------------------ * - * envelope unwrapping, filters, text ops, externalized results - * ------------------------------------------------------------------ */ - -describe("entry-map: envelope + filtering + text ops", () => { - test("unwrapPath reads both the wrapped and the bare item shape", async () => { - const spec = { unwrapPath: "payload", rolePath: "role", content: { path: "content" }, tsPath: "timestamp" }; - const messages = await mapEntries(spec, [ - { timestamp: 1700000000000, type: "response_item", payload: { type: "message", role: "user", content: "hi" } }, - { timestamp: 1700000001000, role: "assistant", content: "yo" }, - ]); - assert.deepEqual(roles(messages), ["user", "assistant"]); - assert.equal(messages[0].content, "hi"); - // The timestamp lives on the envelope, not on the unwrapped payload. - assert.equal(messages[0].createdAt, new Date(1700000000000).toISOString()); - assert.equal(messages[1].content, "yo"); - }); - - test("match keeps only the configured entry kinds", async () => { - const spec = { match: { path: "type", in: ["message"] }, rolePath: "role", content: { path: "content" } }; - const messages = await mapEntries(spec, [ - { type: "message", role: "user", content: "kept" }, - { type: "ai-title", role: "user", content: "dropped" }, - ]); - assert.deepEqual(messages.map((m) => m.content), ["kept"]); - }); - - test("skipTypes drops sidechain branches", async () => { - const spec = { - rolePath: "role", - content: { path: "content" }, - skipTypePath: "isSidechain", - skipTypes: [true], - }; - const messages = await mapEntries(spec, [ - { role: "user", content: "main" }, - { role: "assistant", content: "sidechain", isSidechain: true }, - ]); - assert.deepEqual(messages.map((m) => m.content), ["main"]); - }); - - test("drop is role-scoped: injected user text goes, assistant text stays", async () => { - const spec = { - rolePath: "role", - content: { path: "content" }, - drop: { startsWith: ["<", "# AGENTS.md"], roles: ["user"] }, - }; - const messages = await mapEntries(spec, [ - { role: "user", content: "ignore" }, - { role: "user", content: "# AGENTS.md instructions" }, - { role: "user", content: "real question" }, - { role: "assistant", content: "keep me" }, - ]); - assert.deepEqual(messages.map((m) => m.content), ["real question", "keep me"]); - }); - - test("textOps strip injected blocks and extract the real query", () => { - const ops = [ - { op: "stripXmlBlocks", tags: ["system-reminder", "cb_summary"], roles: ["user"] }, - { op: "extractXmlTag", tag: "user_query", roles: ["user"] }, - ]; - assert.equal( - applyTextOps("noisehello", ops, "user"), - "hello", - ); - assert.equal( - applyTextOps("the actual prompt", ops, "user"), - "the actual prompt", - ); - // role-scoped: the same text survives on an assistant message - assert.equal( - applyTextOps("the actual prompt", ops, "assistant"), - "the actual prompt", - ); - // a malformed tag name is ignored rather than turned into a regex - assert.equal(applyTextOps("keep .* me", [{ op: "stripXmlBlocks", tags: [".*"] }], "user"), "keep .* me"); - }); -}); - -describe("entry-map: externalized result follow-up", () => { - const spec = (marker) => ({ - rolePath: "role", - toolCall: { - result: { - typePath: "type", - types: ["function_call_result"], - idPath: "callId", - resultPath: "output", - follow: { marker: marker ?? "Full output saved to:" }, - }, - }, - }); - - test("reads the real output back from inside the data root", async () => { - const root = fs.mkdtempSync(path.join(tmpRoot, "follow-")); - fs.writeFileSync(path.join(root, "out.txt"), "FULL OUTPUT BODY\n"); - const messages = await mapEntries( - spec(), - [ - { type: "function_call", callId: "c1", name: "bash" }, - { - type: "function_call_result", - callId: "c1", - output: ` Output too large. Full output saved to: ${path.join(root, "out.txt")}`, - }, - ], - { root }, - ); - assert.equal(messages[0].content, "FULL OUTPUT BODY"); - }); - - test("refuses a pointer outside the data root", async () => { - const root = fs.mkdtempSync(path.join(tmpRoot, "follow-")); - const elsewhere = fs.mkdtempSync(path.join(tmpRoot, "elsewhere-")); - const target = path.join(elsewhere, "secret.txt"); - fs.writeFileSync(target, "SHOULD NOT BE READ"); - const messages = await mapEntries( - spec(), - [ - { type: "function_call", callId: "c1", name: "bash" }, - { type: "function_call_result", callId: "c1", output: `Full output saved to: ${target}` }, - ], - { root }, - ); - assert.ok(messages[0].content.includes("Full output saved to:")); - assert.ok(!messages[0].content.includes("SHOULD NOT BE READ")); - }); - - test("without a root there is nothing to follow", async () => { - const messages = await mapEntries( - spec(), - [ - { type: "function_call", callId: "c1", name: "bash" }, - { type: "function_call_result", callId: "c1", output: "Full output saved to: /etc/passwd" }, - ], - {}, - ); - assert.ok(messages[0].content.includes("/etc/passwd")); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/forge.test.mjs b/plugins/io.github.muzimu217.session-import/test/forge.test.mjs deleted file mode 100644 index f0c36c5..0000000 --- a/plugins/io.github.muzimu217.session-import/test/forge.test.mjs +++ /dev/null @@ -1,328 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/forge.js — corpus budgeting, rate limiting, distillation - * entry point. Runs without an Electron host: builds a fake `pi` on - * globalThis and exercises the module's exported functions directly. - * - * Style: node --test (no extra deps). Run with: - * node --test test/forge.test.mjs - */ -"use strict"; - -import { test, describe, beforeEach } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); - -// Each test starts with a clean slate — clear require cache for forge.js so -// its module-level state (callLog) resets. -function freshForge() { - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - return require(`${PLUGIN_DIR}/lib/forge.js`); -} - -describe("forge.buildCorpus", () => { - const forge = freshForge(); - - test("returns empty string for empty input", () => { - assert.strictEqual(forge.buildCorpus([]), ""); - }); - - test("emits title block + user/assistant/tools with role labels", () => { - const sessions = [ - { - title: "T1", - projectPath: "/tmp/proj", - modelId: "claude-opus", - messages: [ - { role: "user", content: "hi" }, - { role: "assistant", content: "hello" }, - { role: "tool", content: "ran" }, - ], - }, - ]; - const out = forge.buildCorpus(sessions, 100_000); - assert.match(out, /## 会话:T1/); - assert.match(out, /- 项目:\/tmp\/proj/); - assert.match(out, /- 模型:claude-opus/); - assert.match(out, /\*\*用户\*\*: hi/); - assert.match(out, /\*\*助手\*\*: hello/); - assert.match(out, /\*\*工具\*\*: ran/); - }); - - test("trims whitespace-only messages and skips them", () => { - const out = forge.buildCorpus( - [{ title: "T", messages: [{ role: "user", content: " \n " }, { role: "user", content: "ok" }] }], - 100_000, - ); - assert.ok(!out.includes(" \n "), "whitespace-only chunk not emitted"); - assert.match(out, /\*\*用户\*\*: ok/); - }); - - test("budget split is per-session; over budget, tail is dropped, head kept", () => { - // budget 100 chars, 2 sessions → 50 each - const big = "x".repeat(80); - const sessions = [ - { - title: "BIG", - messages: [ - { role: "user", content: big }, - { role: "user", content: "TAIL-MARKER" }, - ], - }, - { title: "OTHER", messages: [{ role: "user", content: "other" }] }, - ]; - const out = forge.buildCorpus(sessions, 100); - assert.ok(out.includes("BIG"), "title present"); - assert.ok(out.includes("TAIL-MARKER") === false, "tail dropped on overflow"); - assert.ok(out.includes("other"), "second session rendered"); - }); - - test("budget split: per-session cap clamps large body (head kept, tail dropped)", () => { - // 1 session, budget 1000. Header ~7 chars; per-session budget ~993. - // First message chunk = "**用户**: " + 1000 y's + "\n" = ~1006 chars > 1000 - // so the body is dropped entirely and TAIL-MARKER falls outside too. - const sessions = [ - { - title: "T", - messages: [ - { role: "user", content: "y".repeat(1000) }, - { role: "user", content: "TAIL-MARKER" }, - ], - }, - ]; - const out = forge.buildCorpus(sessions, 1000); - const yCount = (out.match(/y/g) || []).length; - assert.ok(yCount <= 1000, `body clamped: yCount=${yCount}`); - assert.ok(!out.includes("TAIL-MARKER"), "tail truncated when per-session budget exceeded"); - assert.match(out, /## 会话:T/, "header still rendered"); - }); - - test("budget split: when first message alone exceeds budget, NOTHING is emitted (defensive)", () => { - // Documents current behavior — single huge message gets broken by `if (used + chunk.length > perSession) break;`, - // meaning a too-big first message yields no body text at all (header only). - // This is a known wart the spec should not regress silently: covered here. - const huge = "z".repeat(5000); - const out = forge.buildCorpus( - [{ title: "T", messages: [{ role: "user", content: huge }] }], - 1000, - ); - const zCount = (out.match(/z/g) || []).length; - assert.strictEqual(zCount, 0, "first-chunk-skipped when it alone exceeds per-session budget"); - assert.match(out, /## 会话:T/); - }); - - test("joins sessions with --- separator", () => { - const out = forge.buildCorpus( - [ - { title: "A", messages: [{ role: "user", content: "1" }] }, - { title: "B", messages: [{ role: "user", content: "2" }] }, - ], - 10_000, - ); - assert.match(out, /\n\n---\n\n/); - }); - - test("system and unknown roles render as user label (defensive)", () => { - const out = forge.buildCorpus( - [{ title: "T", messages: [{ role: "system", content: "sysnote" }, { role: "weird", content: "?" }] }], - 10_000, - ); - // The mapping is `assistant -> 助手`, `tool -> 工具`, else -> 用户. - // system + weird both fall through to "用户". - assert.ok(out.includes("sysnote")); - assert.ok(out.includes("?")); - }); -}); - -describe("forge.distill (rate limit + error paths)", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - }); - - test("RATE_LIMITED thrown when callLog already full within window", async () => { - // Fresh module so callLog is empty at start. - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - // After 8 successful calls the 9th rejects. Use a fake pi returning minimal text. - globalThis.pi = { - agent: { - complete: async () => ({ - text: "ok", - usage: { inputTokens: 1, outputTokens: 1 }, - }), - }, - }; - const sessions = [{ title: "T", messages: [{ role: "user", content: "hi" }] }]; - for (let i = 0; i < 8; i++) { - await forge.distill({ sessions, modelKey: "m", thinkingLevel: "off", goal: "" }); - } - await assert.rejects( - () => forge.distill({ sessions, modelKey: "m", thinkingLevel: "off", goal: "" }), - (err) => { - assert.strictEqual(err.code, "RATE_LIMITED"); - assert.ok(err.retryAfterMs > 0 && err.retryAfterMs <= 60_000); - return true; - }, - ); - }); - - test("'没有可蒸馏的会话内容' when sessions array is empty", async () => { - // Fresh module so module-level callLog starts empty. - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - let called = false; - globalThis.pi = { - agent: { complete: async () => { called = true; return { text: "" }; } }, - }; - await assert.rejects( - () => forge.distill({ sessions: [] }), - /没有可蒸馏的会话内容/, - ); - assert.strictEqual(called, false, "agent.complete must not be called when corpus is empty"); - }); - - test("messages=[] still emits title-only corpus and completes (defensive: not silently dropped)", async () => { - // Documents current behavior: an empty messages array in one session - // does NOT cause `distill` to throw — the title alone makes the corpus - // non-empty, so the agent is consulted (and may return distilled text). - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - let captured = null; - globalThis.pi = { - agent: { - complete: async (input) => { - captured = input; - return { text: "STILL-RAN", usage: { inputTokens: 5, outputTokens: 3 } }; - }, - }, - }; - const out = await forge.distill({ sessions: [{ title: "Lonely Session", messages: [] }] }); - assert.strictEqual(out.text, "STILL-RAN"); - assert.ok(captured.messages[0].content.includes("## 会话:Lonely Session")); - }); - - test("happy path: returns text + usage, builds corpus, goal prepended to prompt", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - let captured; - globalThis.pi = { - agent: { - complete: async (input) => { - captured = input; - return { - text: "## 项目约定\n- always read first", - usage: { inputTokens: 100, outputTokens: 50 }, - }; - }, - }, - }; - const sessions = [{ title: "T", messages: [{ role: "user", content: "hello" }] }]; - const out = await forge.distill({ - sessions, - modelKey: "claude-opus", - thinkingLevel: "high", - goal: "关注部署", - }); - assert.strictEqual(out.text, "## 项目约定\n- always read first"); - assert.deepStrictEqual(out.usage, { inputTokens: 100, outputTokens: 50 }); - assert.ok(captured); - assert.strictEqual(captured.modelKey, "claude-opus"); - assert.strictEqual(captured.thinkingLevel, "high"); - assert.match(captured.system, /你是一位资深工程负责人/); - assert.ok(captured.messages[0].content.includes("本次提炼的重点:关注部署")); - assert.ok(captured.messages[0].content.includes("## 会话:T")); - }); - - test("extractText: handles string, text, content string, content[]", () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - // internal helper, access via distill returning the fake result - // We rely on the implementation: probe by returning varying shapes from pi. - const cases = [ - { in: "plain", expect: "plain" }, - { in: { text: "via.text" }, expect: "via.text" }, - { in: { content: "via.content" }, expect: "via.content" }, - { in: { content: [{ type: "text", text: "block" }] }, expect: "block" }, - { in: { content: [{ text: "no-type" }] }, expect: "no-type" }, - { in: null, expect: "" }, - ]; - globalThis.pi = { agent: { complete: async () => "init" } }; - return (async () => { - for (const c of cases) { - globalThis.pi.agent.complete = async () => c.in; - const r = await forge.distill({ sessions: [{ title: "T", messages: [{ role: "user", content: "x" }] }] }); - assert.strictEqual(r.text, c.expect, `for input ${JSON.stringify(c.in)}`); - } - })(); - }); -}); - -describe("forge.listImported / readMessages", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/forge.js`)]; - }); - - test("listImported passes through {sessions} wrapper", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - const sessions = [{ id: 1, title: "a" }]; - globalThis.pi = { session: { list: async (args) => ({ sessions, limit: args.limit }) } }; - const got = await forge.listImported({ limit: 200 }); - // legacy {sessions} shape: entries gain the sessionId projection - assert.deepStrictEqual(got, [{ id: 1, title: "a", sessionId: 1 }]); - }); - - test("listImported unwraps bare array responses (legacy host)", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - const sessions = [{ id: 2 }]; - globalThis.pi = { session: { list: async () => sessions } }; - const got = await forge.listImported(); - assert.deepStrictEqual(got, [{ id: 2, sessionId: 2 }]); - }); - - test("listImported prefers official {items} wrapper with sessionId", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - globalThis.pi = { - session: { list: async () => ({ items: [{ sessionId: "abc-1", title: "official" }] }) }, - }; - const got = await forge.listImported(); - assert.deepStrictEqual(got, [{ sessionId: "abc-1", title: "official", id: "abc-1" }]); - }); - - test("listImported clamps limit to [1, 200]", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - const seen = []; - globalThis.pi = { session: { list: async (args) => { seen.push(args.limit); return []; } } }; - await forge.listImported({ limit: 999 }); - await forge.listImported({ limit: -5 }); - await forge.listImported({ limit: "abc" }); - assert.deepStrictEqual(seen, [200, 1, 100]); - }); - - test("readMessages unwraps array vs {messages} envelope", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - globalThis.pi = { session: { listMessages: async () => ([{ id: 1 }]) } }; - assert.deepStrictEqual(await forge.readMessages("s1"), [{ id: 1 }]); - globalThis.pi = { session: { listMessages: async () => ({ messages: [{ id: 2 }] }) } }; - assert.deepStrictEqual(await forge.readMessages("s2"), [{ id: 2 }]); - }); - - test("listImported forwards source when provided", async () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - let got; - globalThis.pi = { session: { list: async (args) => { got = args; return []; } } }; - await forge.listImported({ source: "zcode", limit: 10 }); - assert.strictEqual(got.source, "zcode"); - }); -}); - -describe("forge.COMPLETE_LIMITS reflects ADR 0174", () => { - test("constants match the docs", () => { - const forge = require(`${PLUGIN_DIR}/lib/forge.js`); - assert.strictEqual(forge.COMPLETE_LIMITS.systemMax, 32 * 1024); - assert.strictEqual(forge.COMPLETE_LIMITS.messagesMaxChars, 200_000); - assert.strictEqual(forge.COMPLETE_LIMITS.callsPerMinute, 8); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/history.test.mjs b/plugins/io.github.muzimu217.session-import/test/history.test.mjs deleted file mode 100644 index f4b67aa..0000000 --- a/plugins/io.github.muzimu217.session-import/test/history.test.mjs +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/history.js — JSONL distillation-history ring buffer. - * Run: node --test test/history.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const { - HISTORY_LIMIT, - HISTORY_PATH, - parseJsonl, - serializeEntry, - toJsonl, - addEntry, -} = require("../lib/history.js"); - -describe("lib/history parseJsonl", () => { - test("empty / blank input yields []", () => { - assert.deepStrictEqual(parseJsonl(""), []); - assert.deepStrictEqual(parseJsonl(" \n\n "), []); - }); - - test("parses one entry per non-blank line", () => { - const text = [ - JSON.stringify({ a: 1 }), - "", - JSON.stringify({ b: 2 }), - ].join("\n"); - assert.deepStrictEqual(parseJsonl(text), [{ a: 1 }, { b: 2 }]); - }); - - test("skips malformed lines instead of throwing", () => { - const text = [JSON.stringify({ a: 1 }), "not json {", JSON.stringify({ b: 2 })].join("\n"); - assert.deepStrictEqual(parseJsonl(text), [{ a: 1 }, { b: 2 }]); - }); -}); - -describe("lib/history addEntry cap", () => { - test("keeps the most recent HISTORY_LIMIT entries", () => { - let entries = []; - for (let i = 0; i < HISTORY_LIMIT + 10; i++) entries = addEntry(entries, { n: i }); - assert.strictEqual(entries.length, HISTORY_LIMIT); - assert.strictEqual(entries[0].n, 10); // oldest survivor - assert.strictEqual(entries[entries.length - 1].n, HISTORY_LIMIT + 9); // newest - }); -}); - -describe("lib/history round-trip", () => { - test("toJsonl -> parseJsonl preserves entries", () => { - const entries = [{ a: 1 }, { b: 2, c: "x" }]; - const back = parseJsonl(toJsonl(entries)); - assert.deepStrictEqual(back, entries); - }); - - test("serializeEntry emits a single line", () => { - assert.strictEqual(serializeEntry({ a: 1 }).includes("\n"), false); - }); - - test("HISTORY_PATH lives under the docs/** fs scope", () => { - assert.ok(HISTORY_PATH.startsWith("docs/"), HISTORY_PATH); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/i18n.test.mjs b/plugins/io.github.muzimu217.session-import/test/i18n.test.mjs deleted file mode 100644 index f104dba..0000000 --- a/plugins/io.github.muzimu217.session-import/test/i18n.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/i18n.js — the view-side i18n dictionary + getMessage. - * - * Run: - * node --test test/i18n.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const { getMessage, detectLocale, DICT, LOCALES } = require("../lib/i18n.js"); - -describe("lib/i18n getMessage", () => { - test("zh-CN resolves the Chinese string", () => { - assert.strictEqual(getMessage("zh-CN", "importer.scan"), "扫描本机工具"); - }); - - test("en resolves the English string", () => { - assert.strictEqual(getMessage("en", "importer.scan"), "Scan local tools"); - }); - - test("unknown locale falls back to zh-CN", () => { - assert.strictEqual(getMessage("fr", "importer.scan"), "扫描本机工具"); - }); - - test("missing key falls back to the key itself", () => { - assert.strictEqual(getMessage("en", "does.not.exist"), "does.not.exist"); - }); - - test("interpolates {count} placeholder", () => { - assert.strictEqual( - getMessage("zh-CN", "importer.count.sessions", { count: 12 }), - "12 个会话", - ); - assert.strictEqual( - getMessage("en", "importer.count.sessions", { count: 12 }), - "12 sessions", - ); - }); - - test("resolves the importDone.done key in zh-CN", () => { - assert.strictEqual( - getMessage("zh-CN", "importer.importDone.done"), - "——已按项目归组;打开对应项目或在「项目」页查看", - ); - }); - - test("unknown placeholder is left verbatim (no crash)", () => { - assert.strictEqual( - getMessage("en", "importer.imported", { wrong: 5 }), - "Imported {count} sessions", - ); - }); - - test("every zh-CN key has a matching en key", () => { - const en = Object.keys(DICT.en).sort(); - const zh = Object.keys(DICT["zh-CN"]).sort(); - assert.deepStrictEqual(en, zh, "en and zh-CN dictionaries must stay in lockstep"); - }); - - test("LOCALES lists both supported locales", () => { - assert.deepStrictEqual(LOCALES.sort(), ["en", "zh-CN"]); - }); -}); - -describe("lib/i18n detectLocale", () => { - test("zh navigator yields zh-CN", () => { - assert.strictEqual(detectLocale({ language: "zh-CN", languages: ["zh-CN", "en"] }), "zh-CN"); - }); - - test("en navigator yields en", () => { - assert.strictEqual(detectLocale({ language: "en-US", languages: ["en-US"] }), "en"); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/main.test.mjs b/plugins/io.github.muzimu217.session-import/test/main.test.mjs deleted file mode 100644 index 3da1157..0000000 --- a/plugins/io.github.muzimu217.session-import/test/main.test.mjs +++ /dev/null @@ -1,718 +0,0 @@ -// @ts-check -/** - * Unit tests for main.js — capabilities detection, scan cache, convertBatch - * error tolerance, official #169 contract mapping, error observability (F-10). - * - * Style: node --test. The plugin-sdk global `pi` is stubbed. The registry is - * replaced in require.cache so we exercise main.js's switch dispatch. - * - * Run: - * node --test test/main.test.mjs - */ -"use strict"; - -import { test, describe, before, beforeEach, after } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); - -// ---------- helpers ---------- - -/** Replace the registry inside main.js's require cache. */ -function installRegistry(adapters) { - const registryPath = require.resolve(`${PLUGIN_DIR}/lib/registry.js`); - require.cache[registryPath] = { - id: registryPath, - filename: registryPath, - loaded: true, - exports: { - ADAPTERS: adapters, - allAdapters: () => adapters, - getAdapter: (s) => adapters.find((a) => a.source === s) ?? null, - // Extensibility exports (no custom sources in these unit tests). - getDynamicAdapters: () => [], - refreshDynamicSources: async () => [], - getDynamicLoadReport: () => ({ count: 0, errors: [], configPath: null }), - CONFIG_PATH: "docs/session-import-sources.json", - }, - }; -} - -/** Drop main.js from require cache so onLoad/onUnload run fresh each test. */ -function freshMain() { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - return require(`${PLUGIN_DIR}/main.js`); -} - -/** Tiny adapter factory. */ -function makeAdapter(source, { scan = [], convert, failScan = null, failConvert = null } = {}) { - return { - source, - label: source, - scan: async () => (failScan ? Promise.reject(failScan) : scan), - convert: async (item) => (failConvert ? Promise.reject(failConvert) : convert(item)), - }; -} - -// ============================================================================= - -describe("main.onPanelInvoke / import.* dispatch", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - installRegistry([]); // empty by default; tests opt in - }); - - test("import.adapters returns [{source, label}] for each registered adapter", async () => { - installRegistry([ - makeAdapter("a", { scan: [], convert: () => ({ session: {}, messages: [] }) }), - makeAdapter("b", { scan: [], convert: () => ({ session: {}, messages: [] }) }), - ]); - const main = freshMain(); - const out = await main.onPanelInvoke("import.adapters"); - assert.deepStrictEqual(out, [ - { source: "a", label: "a", custom: false, dataPath: null }, - { source: "b", label: "b", custom: false, dataPath: null }, - ]); - }); - - test("unknown channel throws Error", async () => { - const main = freshMain(); - await assert.rejects(() => main.onPanelInvoke("nope"), /unknown channel: nope/); - }); -}); - -describe("F-10 import.scanSource — error observability (P0 of v0.5.0)", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - }); - - test("happy path: adapter.scan returns sessions -> {found: true, count: N}", async () => { - installRegistry([ - makeAdapter("ok", { - scan: [{ source: "ok", externalId: "1", title: "t" }], - convert: () => ({ session: {}, messages: [] }), - }), - ]); - const main = freshMain(); - const r = await main.onPanelInvoke("import.scanSource", { source: "ok" }); - assert.deepStrictEqual(r, { - source: "ok", - found: true, - count: 1, - error: null, - // Adapters without scanFast() complete in one pass. - partial: false, - // Sessions too large for the host contract (surfaced in the UI). - oversized: 0, - }); - }); - - test("F-10: adapter.scan rejects with ERR (path-missing) -> returned shape carries `error`", async () => { - const err = Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" }); - installRegistry([makeAdapter("missing", { failScan: err })]); - const main = freshMain(); - const r = await main.onPanelInvoke("import.scanSource", { source: "missing" }); - assert.strictEqual(r.source, "missing"); - assert.strictEqual(r.found, false); - assert.strictEqual(r.count, 0); - assert.ok(r.error, "error object present"); - assert.strictEqual(r.error.code, "ENOENT"); - assert.match(r.error.message, /ENOENT/); - }); - - test("F-10: permission-denied surfaced distinctly", async () => { - const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - installRegistry([makeAdapter("denied", { failScan: err })]); - const main = freshMain(); - const r = await main.onPanelInvoke("import.scanSource", { source: "denied" }); - assert.strictEqual(r.error.code, "EACCES"); - }); - - test("F-10: corrupt-db (custom error code from SQLite layer) flows through", async () => { - const err = Object.assign(new Error("database disk image is malformed"), { code: "SQLITE_CORRUPT" }); - installRegistry([makeAdapter("corrupt", { failScan: err })]); - const main = freshMain(); - const r = await main.onPanelInvoke("import.scanSource", { source: "corrupt" }); - assert.strictEqual(r.error.code, "SQLITE_CORRUPT"); - assert.strictEqual(r.found, false); - }); - - test("unknown source throws (not silently swallowed)", async () => { - installRegistry([]); - const main = freshMain(); - await assert.rejects(() => main.onPanelInvoke("import.scanSource", { source: "nope" }), /unknown source: nope/); - }); - - test("cache prevents re-scan across same source", async () => { - let scanCalls = 0; - installRegistry([ - { - source: "c", - label: "c", - scan: async () => { - scanCalls += 1; - return [{ source: "c", externalId: "1" }]; - }, - convert: () => ({ session: {}, messages: [] }), - }, - ]); - const main = freshMain(); - await main.onPanelInvoke("import.scanSource", { source: "c" }); - await main.onPanelInvoke("import.scanSource", { source: "c" }); - await main.onPanelInvoke("import.scanSource", { source: "c" }); - assert.strictEqual(scanCalls, 1, "scanned exactly once"); - }); -}); - -describe("import.sessions — pagination-after-scan contract", () => { - beforeEach(() => delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]); - - test("returns the cached scans after scanSource", async () => { - const make = (s) => ({ source: "x", externalId: s, title: `T${s}` }); - installRegistry([ - { - source: "x", - label: "x", - scan: async () => [make("1"), make("2"), make("3")], - convert: () => ({ session: {}, messages: [] }), - }, - ]); - const main = freshMain(); - await main.onPanelInvoke("import.scanSource", { source: "x" }); - const out = await main.onPanelInvoke("import.sessions", { source: "x" }); - assert.strictEqual(out.sessions.length, 3); - assert.strictEqual(out.source, "x"); - }); - - test("refuses to fetch sessions without a prior scan", async () => { - installRegistry([ - makeAdapter("x", { scan: [], convert: () => ({ session: {}, messages: [] }) }), - ]); - const main = freshMain(); - await assert.rejects( - () => main.onPanelInvoke("import.sessions", { source: "x" }), - /source not scanned/, - ); - }); -}); - -describe("import.convertBatch — error tolerance", () => { - beforeEach(() => delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]); - - test("convertBatch assigns UUID to each message even when adapter omits ids", async () => { - installRegistry([ - { - source: "x", - label: "x", - scan: async () => [{ source: "x", externalId: "1" }], - convert: async (item) => ({ - session: { id: "imp-x-1", title: item.externalId, createdAt: "2026-09-09T00:00:00.000Z", updatedAt: "2026-09-09T00:01:00.000Z" }, - messages: [ - { role: "user", content: "hi", createdAt: "2026-09-09T00:00:30.000Z" }, - { role: "assistant", content: "yo", createdAt: "2026-09-09T00:01:00.000Z" }, - ], - }), - }, - ]); - const main = freshMain(); - const out = await main.onPanelInvoke("import.convertBatch", { - source: "x", - items: [{ externalId: "1" }], - }); - assert.strictEqual(out.sessions.length, 1); - for (const m of out.sessions[0].messages) { - assert.match(m.id, /^[0-9a-f-]{36}$/); - } - }); - - test("convertBatch reports unreadable count and skips failed items", async () => { - installRegistry([ - { - source: "x", - label: "x", - scan: async () => [], - convert: async (item) => { - if (item.externalId === "bad") throw new Error("parse fail"); - return { session: { title: "ok" }, messages: [] }; - }, - }, - ]); - const main = freshMain(); - const out = await main.onPanelInvoke("import.convertBatch", { - source: "x", - items: [{ externalId: "good" }, { externalId: "bad" }, { externalId: "good2" }], - }); - assert.strictEqual(out.sessions.length, 2); - assert.strictEqual(out.unreadable, 1); - }); - - test("convertBatch rejects nothing-to-select upfront", async () => { - installRegistry([ - makeAdapter("x", { scan: [], convert: () => ({ session: {}, messages: [] }) }), - ]); - const main = freshMain(); - await assert.rejects( - () => main.onPanelInvoke("import.convertBatch", { source: "x", items: [] }), - /nothing selected/, - ); - }); -}); - -describe("import.capabilities + import.commit fallback", () => { - beforeEach(() => delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]); - - test("capabilities.officialSessionApi = false when pi.session.importBatch missing", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - }; - const main = freshMain(); - const caps = await main.onPanelInvoke("import.capabilities"); - assert.strictEqual(caps.officialSessionApi, false); - }); - - test("capabilities.officialSessionApi = true when pi.session.importBatch present", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { importBatch: async () => ({ imported: 0, skipped: 0 }) }, - }; - const main = freshMain(); - const caps = await main.onPanelInvoke("import.capabilities"); - assert.strictEqual(caps.officialSessionApi, true); - }); - - test("import.commit reports a clear error when no session-import API exists on the host", async () => { - installRegistry([makeAdapter("x", { scan: [], convert: () => ({ session: {}, messages: [] }) })]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - }; - const main = freshMain(); - // No session.importBatch and no session.import on this stub host: the - // commit must surface a clear, actionable error (not a cryptic - // "host api not available: session.importBatch" crash). - await assert.rejects( - () => main.onPanelInvoke("import.commit", { source: "x", items: [{ externalId: "1" }] }), - /feat\/zcode-session-import|当前宿主未提供会话导入接口/, - ); - }); - - test("import.commit uses the legacy session.import bridge when available", async () => { - const importedCalls = []; - installRegistry([ - makeAdapter("x", { - scan: [], - convert: () => ({ session: { id: "s1", title: "T", mode: "agent" }, messages: [{ role: "user", content: "hi", createdAt: "2026-01-01T00:00:00.000Z" }] }), - }), - ]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { - import: async (payload) => { - importedCalls.push(payload); - return { ok: true, imported: true, skipped: false }; - }, - }, - }; - const main = freshMain(); - const r = await main.onPanelInvoke("import.commit", { source: "x", items: [{ externalId: "1" }] }); - assert.strictEqual(r.imported, 1, "legacy bridge imported the session"); - assert.strictEqual(importedCalls.length, 1, "one session.import call"); - assert.strictEqual(importedCalls[0].session.id, "s1"); - assert.strictEqual(importedCalls[0].messages[0].id != null, true, "message id allocated"); - }); -}); - -describe("P0-F1 import.recent — cross-view state channel", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - delete require.cache[require.resolve(`${PLUGIN_DIR}/lib/bus.js`)]; - }); - - test("returns null at/count 0/source null when no import has happened", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - }; - const main = freshMain(); - const r = await main.onPanelInvoke("import.recent"); - assert.strictEqual(r.lastImportAt, null); - assert.strictEqual(r.lastImportCount, 0); - assert.strictEqual(r.lastImportSource, null); - }); - - test("after import.commit success, snapshot carries imported count + source + ISO timestamp", async () => { - installRegistry([ - { - source: "stub", - label: "stub", - scan: async () => [], - convert: async (item) => ({ - session: { id: `s-${item.externalId}`, title: `T${item.externalId}` }, - messages: [], - }), - }, - ]); - const importBatchCalls = []; - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { - importBatch: async (input) => { - importBatchCalls.push(input); - const results = input.sessions.map((s) => ({ - externalId: s.externalId, sessionId: `sid-${s.externalId}`, status: "imported", - })); - return { results, imported: input.sessions.length, skipped: 0, failed: 0 }; - }, - }, - }; - const main = freshMain(); - await main.onPanelInvoke("import.commit", { - source: "stub", - items: [{ externalId: "a" }, { externalId: "b" }, { externalId: "c" }], - }); - const r = await main.onPanelInvoke("import.recent"); - assert.strictEqual(r.lastImportCount, 3); - assert.strictEqual(r.lastImportSource, "stub"); - assert.match(r.lastImportAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - }); - - test("failed-only import (no successful sessions) does NOT update snapshot", async () => { - installRegistry([ - { - source: "stub", - label: "stub", - scan: async () => [], - convert: async () => ({ session: {}, messages: [] }), - }, - ]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { - importBatch: async () => ({ - results: [{ status: "failed", externalId: "x", errorMessage: "boom" }], - imported: 0, skipped: 0, failed: 1, - }), - }, - }; - const main = freshMain(); - await main.onPanelInvoke("import.commit", { source: "stub", items: [{ externalId: "x" }] }); - const r = await main.onPanelInvoke("import.recent"); - assert.strictEqual(r.lastImportCount, 0, "no successful import → snapshot untouched"); - assert.strictEqual(r.lastImportAt, null); - }); -}); - -describe("forge.* dispatch", () => { - beforeEach(() => delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]); - - test("forge.capabilities reports booleans for each dependency", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { list: () => {}, listMessages: () => {} }, - agent: { complete: () => {} }, - models: { list: () => {} }, - fs: { writeText: () => {} }, - }; - const main = freshMain(); - const caps = await main.onPanelInvoke("forge.capabilities"); - assert.deepStrictEqual(caps, { - listSessions: true, - listMessages: true, - complete: true, - models: true, - writeText: true, - }); - }); - - test("forge.models normalizes {models:[]} and bare arrays", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - models: { list: async () => [{ modelKey: "openai/gpt-4o" }] }, - }; - const main = freshMain(); - const out = await main.onPanelInvoke("forge.models"); - assert.deepStrictEqual(out.models, [{ modelKey: "openai/gpt-4o" }]); - }); - - test("forge.save rejects empty path", async () => { - installRegistry([]); - let writeCalls = 0; - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - fs: { writeText: async () => { writeCalls += 1; } }, - }; - const main = freshMain(); - await assert.rejects( - () => main.onPanelInvoke("forge.save", { path: "", content: "x" }), - /path required/, - ); - assert.strictEqual(writeCalls, 0); - }); - - test("forge.save returns {bytes} matching utf8 byte length", async () => { - installRegistry([]); - const writes = new Map(); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - fs: { - writeText: async (path, content) => { writes.set(path, content); }, - }, - }; - const main = freshMain(); - const out = await main.onPanelInvoke("forge.save", { path: "AGENTS.md", content: "你好" }); - assert.strictEqual(out.path, "AGENTS.md"); - assert.strictEqual(out.bytes, Buffer.byteLength("你好", "utf8")); - // P1-F3: forge.save also records a history entry, so assert on the - // target-file write specifically (not the last write overall). - assert.strictEqual(writes.get("AGENTS.md"), "你好"); - }); - - test("forge.save rejects empty content", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - fs: { writeText: async () => {} }, - }; - const main = freshMain(); - await assert.rejects( - () => main.onPanelInvoke("forge.save", { path: "AGENTS.md", content: " " }), - /nothing to save/, - ); - }); -}); - -describe("forge.distill dispatch wiring", () => { - beforeEach(() => delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]); - - test("forge.distill rejects when no sessions selected", async () => { - installRegistry([]); - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { listMessages: async () => [] }, - }; - const main = freshMain(); - await assert.rejects( - () => main.onPanelInvoke("forge.distill", { sessionIds: [] }), - /no sessions selected/, - ); - }); - - test("forge.distill aggregates unreadable count and surfaces RATE_LIMITED", async () => { - installRegistry([]); - let piCompleted = false; - globalThis.pi = { - commands: { register: async () => {}, unregister: async () => {} }, - ui: { openPanel: async () => {}, showToast: async () => {} }, - session: { - list: async () => ({ sessions: [{ id: "s1" }, { id: "s2" }, { id: "s3" }] }), - listMessages: async (q) => { - if (q.sessionId === "s2") throw new Error("read fail"); - return [{ role: "user", content: "x" }]; - }, - }, - agent: { - complete: async () => { - piCompleted = true; - return { text: "distilled", usage: { inputTokens: 5, outputTokens: 7 } }; - }, - }, - }; - const main = freshMain(); - // Pre-fill rate window: forge.distill uses module-level callLog. We can't - // easily peek, so we exhaust it via the public API. - for (let i = 0; i < 8; i++) { - // 8 calls with non-empty text -> success, advances callLog - try { - await main.onPanelInvoke("forge.distill", { - sessionIds: ["s1"], - modelKey: "test-model", - sessions: [{ id: "s1", title: "T" }], - }); - } catch (e) { - if (e.code !== "RATE_LIMITED") throw e; - } - } - // 9th must surface RATE_LIMITED - await assert.rejects( - () => main.onPanelInvoke("forge.distill", { - sessionIds: ["s1"], - modelKey: "test-model", - sessions: [{ id: "s1", title: "T" }], - }), - (err) => err.code === "RATE_LIMITED", - ); - }); -}); - -describe("onLoad + onUnload", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - }); - - test("registers exactly three commands (importer + forge entry + forge show)", async () => { - installRegistry([]); - const registered = []; - globalThis.pi = { - commands: { register: async (cmd) => { registered.push(cmd.id); } }, - ui: { openPanel: async () => {}, showToast: async () => {}, notify: async () => ({ ok: true }) }, - }; - const main = freshMain(); - await main.onLoad(); - assert.deepStrictEqual(registered, [ - "session-import.open", - "session-forge.show", - "session-forge.open", - ]); - - const unregistered = []; - globalThis.pi.commands.unregister = async (id) => { unregistered.push(id); }; - await main.onUnload(); - assert.deepStrictEqual(unregistered, [ - "session-import.open", - "session-forge.open", - "session-forge.show", - ]); - }); -}); - -describe("P1-U1 app.locale — best-effort host locale", () => { - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - installRegistry([]); - }); - - test("returns zh-CN when host exposes no locale", async () => { - globalThis.pi = { ui: {} }; - const main = freshMain(); - assert.strictEqual(await main.onPanelInvoke("app.locale"), "zh-CN"); - delete globalThis.pi; - }); - - test("prefers pi.i18n.locale", async () => { - globalThis.pi = { i18n: { locale: "en" }, ui: {} }; - const main = freshMain(); - assert.strictEqual(await main.onPanelInvoke("app.locale"), "en"); - delete globalThis.pi; - }); - - test("normalizes bare 'zh' to 'zh-CN'", async () => { - globalThis.pi = { app: { locale: "zh" }, ui: {} }; - const main = freshMain(); - assert.strictEqual(await main.onPanelInvoke("app.locale"), "zh-CN"); - delete globalThis.pi; - }); - - test("rejects unsupported locale and falls back to zh-CN", async () => { - globalThis.pi = { i18n: { locale: "fr" }, ui: {} }; - const main = freshMain(); - assert.strictEqual(await main.onPanelInvoke("app.locale"), "zh-CN"); - delete globalThis.pi; - }); -}); - -describe("P1-F3+F4 forge.save (modes) + forge.history", () => { - const { HISTORY_PATH } = require(`${PLUGIN_DIR}/lib/history.js`); - const { MERGE_START, MERGE_END } = require(`${PLUGIN_DIR}/lib/save-modes.js`); - - function makeFs() { - const store = new Map(); - return { - store, - readText: async (p) => { - if (!store.has(p)) { - const e = new Error("ENOENT"); - e.code = "ENOENT"; - throw e; - } - return store.get(p); - }, - writeText: async (p, c) => { store.set(p, c); }, - }; - } - - beforeEach(() => { - delete require.cache[require.resolve(`${PLUGIN_DIR}/main.js`)]; - installRegistry([]); - }); - after(() => { delete globalThis.pi; }); - - test("overwrite: writes incoming, no read, records history", async () => { - const fs = makeFs(); - globalThis.pi = { fs, session: {}, agent: {}, models: {}, ui: {} }; - const main = freshMain(); - const res = await main.onPanelInvoke("forge.save", { path: "AGENTS.md", content: "HELLO", mode: "overwrite" }); - assert.strictEqual(res.mode, "overwrite"); - assert.strictEqual(fs.store.get("AGENTS.md"), "HELLO"); - assert.ok(fs.store.has(HISTORY_PATH), "history file written"); - }); - - test("append: joins new text after existing with a blank line", async () => { - const fs = makeFs(); - fs.store.set("AGENTS.md", "OLD"); - globalThis.pi = { fs, session: {}, agent: {}, models: {}, ui: {} }; - const main = freshMain(); - const res = await main.onPanelInvoke("forge.save", { path: "AGENTS.md", content: "NEW", mode: "append" }); - assert.strictEqual(fs.store.get("AGENTS.md"), "OLD\n\nNEW"); - assert.strictEqual(res.mode, "append"); - }); - - test("merge: replaces only the marked section", async () => { - const fs = makeFs(); - fs.store.set( - "AGENTS.md", - ["# H", "", MERGE_START, "X", MERGE_END, "", "foot"].join("\n"), - ); - globalThis.pi = { fs, session: {}, agent: {}, models: {}, ui: {} }; - const main = freshMain(); - const res = await main.onPanelInvoke("forge.save", { path: "AGENTS.md", content: "Y", mode: "merge" }); - const out = fs.store.get("AGENTS.md"); - assert.ok(out.startsWith("# H")); - assert.ok(out.includes("foot")); - assert.ok(out.includes("Y")); - assert.ok(!out.includes("\nX\n")); - assert.strictEqual(res.mode, "merge"); - }); - - test("forge.history.load parses the JSONL ring buffer", async () => { - const fs = makeFs(); - fs.store.set(HISTORY_PATH, JSON.stringify({ a: 1 }) + "\n"); - globalThis.pi = { fs, session: {}, agent: {}, models: {}, ui: {} }; - const main = freshMain(); - const r = await main.onPanelInvoke("forge.history.load", {}); - assert.deepStrictEqual(r.entries, [{ a: 1 }]); - }); - - test("forge.history.record appends an entry and returns the count", async () => { - const fs = makeFs(); - globalThis.pi = { fs, session: {}, agent: {}, models: {}, ui: {} }; - const main = freshMain(); - const r = await main.onPanelInvoke("forge.history.record", { entry: { path: "AGENTS.md" } }); - assert.strictEqual(r.count, 1); - assert.ok(fs.store.get(HISTORY_PATH).includes("AGENTS.md")); - }); -}); - -after(() => { - // Reset global pi to avoid leaking into sibling test files. - delete globalThis.pi; -}); diff --git a/plugins/io.github.muzimu217.session-import/test/opencode-source.test.mjs b/plugins/io.github.muzimu217.session-import/test/opencode-source.test.mjs deleted file mode 100644 index a73101e..0000000 --- a/plugins/io.github.muzimu217.session-import/test/opencode-source.test.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// OpenCode adapter tests (#682): candidate path resolution per platform and -// scan/convert against a real temporary SQLite database in the v1.x shape. -import assert from "node:assert/strict"; -import test from "node:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const adapter = require("../lib/sources/opencode.js"); - -function tmpdir() { - return fs.mkdtempSync(path.join(os.tmpdir(), "opencode-adapter-")); -} - -/** Minimal v1.x database: one session, one user text, one assistant text+tool. */ -function buildDb(dir, { sessionTitle = "Adapter test session", directory = "/tmp/proj" } = {}) { - const { DatabaseSync } = require("node:sqlite"); - fs.mkdirSync(dir, { recursive: true }); - const dbPath = path.join(dir, "opencode.db"); - const db = new DatabaseSync(dbPath); - db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, - slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL, - share_url TEXT, summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, - summary_diffs TEXT, revert TEXT, permission TEXT, - time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, - time_compacting INTEGER, time_archived INTEGER)`); - db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, - time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL)`); - db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, - session_id TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, - data TEXT NOT NULL)`); - - const t0 = 1770000000000; - db.prepare("INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?,?,?,?,?,?,?,?)") - .run("ses_test1", "proj1", "slug", directory, sessionTitle, "1.2.10", t0, t0 + 5000); - - db.prepare("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)") - .run("msg_u1", "ses_test1", t0 + 100, t0 + 100, - JSON.stringify({ role: "user", time: { created: t0 + 100 } })); - db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)") - .run("p1", "msg_u1", "ses_test1", t0 + 101, t0 + 101, JSON.stringify({ type: "text", text: "hello opencode" })); - - db.prepare("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)") - .run("msg_a1", "ses_test1", t0 + 200, t0 + 200, - JSON.stringify({ role: "assistant", time: { created: t0 + 200, completed: t0 + 300 }, - modelID: "test-model", providerID: "opencode" })); - db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)") - .run("p2", "msg_a1", "ses_test1", t0 + 201, t0 + 201, JSON.stringify({ type: "text", text: "running it" })); - db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)") - .run("p3", "msg_a1", "ses_test1", t0 + 202, t0 + 202, - JSON.stringify({ type: "tool", callID: "c1", tool: "bash", - state: { status: "completed", input: { command: "pwd" }, output: "/tmp/proj" } })); - db.close(); - return dbPath; -} - -test("dbPathCandidates covers LOCALAPPDATA, XDG, and the unix default in order", () => { - const home = "/home/tester"; - const candidates = adapter.dbPathCandidates({ home, env: { LOCALAPPDATA: "C:\\Users\\t\\AppData\\Local", XDG_DATA_HOME: "/xdg/data" } }); - assert.deepEqual(candidates, [ - path.join("C:\\Users\\t\\AppData\\Local", "opencode", "data", "opencode.db"), - path.join("/xdg/data", "opencode", "opencode.db"), - path.join(home, ".local", "share", "opencode", "opencode.db"), - ]); -}); - -test("dbPathCandidates without LOCALAPPDATA/XDG falls back to ~/.local/share", () => { - const candidates = adapter.dbPathCandidates({ home: "/home/tester", env: {} }); - assert.deepEqual(candidates, [path.join("/home/tester", ".local", "share", "opencode", "opencode.db")]); -}); - -test("resolveDbPath picks the first existing candidate (Windows layout wins when present)", () => { - const home = tmpdir(); - const localAppData = tmpdir(); - const xdg = tmpdir(); - // Only the LOCALAPPDATA layout exists -> it must win over the other candidates. - fs.mkdirSync(path.join(localAppData, "opencode", "data"), { recursive: true }); - const dbPath = path.join(localAppData, "opencode", "data", "opencode.db"); - fs.writeFileSync(dbPath, "placeholder"); - assert.equal( - adapter.resolveDbPath({ home, env: { LOCALAPPDATA: localAppData, XDG_DATA_HOME: xdg } }), - dbPath, - ); - // Nothing exists anywhere -> null (source reports "not detected"). - fs.rmSync(dbPath); - assert.equal(adapter.resolveDbPath({ home, env: { LOCALAPPDATA: localAppData, XDG_DATA_HOME: xdg } }), null); -}); - -test("resolveDbPath finds the XDG layout when the Windows layout is absent", () => { - const home = tmpdir(); - const xdg = tmpdir(); - const dbPath = path.join(xdg, "opencode", "opencode.db"); - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - fs.writeFileSync(dbPath, "placeholder"); - assert.equal(adapter.resolveDbPath({ home, env: { XDG_DATA_HOME: xdg } }), dbPath); -}); - -test("scan discovers sessions from a real v1.x database at the unix path", async () => { - const home = tmpdir(); - buildDb(path.join(home, ".local", "share", "opencode"), { sessionTitle: "Scan me" }); - // scan() reads the real environment; point HOME-like inputs via env override. - const sessions = await scanWithHome(home); - assert.equal(sessions.length, 1); - const [session] = sessions; - assert.equal(session.source, "opencode"); - assert.equal(session.externalId, "ses_test1"); - assert.equal(session.title, "Scan me"); - assert.equal(session.messageCount, 2); - assert.ok(session.createdAt.startsWith("2026")); -}); - -test("convert restores text and tool messages in stored order", async () => { - const home = tmpdir(); - buildDb(path.join(home, ".local", "share", "opencode")); - const sessions = await scanWithHome(home); - const { session, messages } = await adapter.convert(sessions[0]); - assert.equal(session.id, "import-opencode-ses_test1"); - assert.equal(session.modelId, "test-model"); - assert.equal(session.providerId, "opencode"); - // user text, assistant text, assistant tool — in part order. - assert.deepEqual( - messages.map((m) => [m.role, m.content]), - [ - ["user", "hello opencode"], - ["assistant", "running it"], - ["tool", "/tmp/proj"], - ], - ); - assert.equal(messages[2].toolName, "bash"); - assert.equal(messages[2].toolStatus, "success"); -}); - -test("scan reports nothing when no database exists anywhere", async () => { - const sessions = await scanWithHome(tmpdir()); - assert.deepEqual(sessions, []); -}); - -/** Runs adapter.scan() with HOME-like env pointing at a scratch root. */ -async function scanWithHome(home) { - const realHomedir = os.homedir(); - const realEnv = { ...process.env }; - process.env.HOME = home; - delete process.env.XDG_DATA_HOME; - delete process.env.LOCALAPPDATA; - // os.homedir() on darwin/linux honours $HOME; Windows keeps USERPROFILE, so - // also override it for consistency. - process.env.USERPROFILE = home; - try { - return await adapter.scan(); - } finally { - process.env.HOME = realEnv.HOME; - process.env.USERPROFILE = realEnv.USERPROFILE; - if (realEnv.XDG_DATA_HOME) process.env.XDG_DATA_HOME = realEnv.XDG_DATA_HOME; - if (realEnv.LOCALAPPDATA) process.env.LOCALAPPDATA = realEnv.LOCALAPPDATA; - } -} diff --git a/plugins/io.github.muzimu217.session-import/test/registry.test.mjs b/plugins/io.github.muzimu217.session-import/test/registry.test.mjs deleted file mode 100644 index db9bda7..0000000 --- a/plugins/io.github.muzimu217.session-import/test/registry.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -// @ts-check -/** - * Tests for lib/registry.js — every supported source id must be reachable - * via getAdapter, and each adapter must expose scan/convert functions with - * the documented shape. - * - * Run: - * node --test test/registry.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); -const { ADAPTERS, getAdapter } = require(`${PLUGIN_DIR}/lib/registry.js`); - -const EXPECTED_SOURCES = ["zcode", "workbuddy", "claude-code", "codex", "opencode", "pi"]; - -describe("registry", () => { - test("ADAPTERS has exactly 6 sources (mandatory by ADR 0008)", () => { - assert.strictEqual(ADAPTERS.length, 6); - }); - - for (const source of EXPECTED_SOURCES) { - test(`registers source ${source}`, () => { - const a = getAdapter(source); - assert.ok(a, `getAdapter("${source}") returned falsy`); - assert.strictEqual(a.source, source); - assert.strictEqual(typeof a.scan, "function"); - assert.strictEqual(typeof a.convert, "function"); - assert.ok(a.label && typeof a.label === "string", "has a non-empty label"); - }); - } - - test("getAdapter returns null for unknown source", () => { - assert.strictEqual(getAdapter("nope"), null); - assert.strictEqual(getAdapter(""), null); - }); - - test("all sources have a non-empty label (UI dependency)", () => { - for (const a of ADAPTERS) { - assert.ok(a.label && a.label.length > 0, `label for ${a.source} is empty`); - } - }); - - test("all source ids match the plugin-sdk contributes.sessionSources regex", () => { - // plugin-sdk/src/index.ts: id 正则 ^[a-zA-Z][a-zA-Z0-9._-]{0,63}$ - const re = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; - for (const a of ADAPTERS) { - assert.match(a.source, re, `${a.source} does not match the source id regex`); - } - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/save-modes.test.mjs b/plugins/io.github.muzimu217.session-import/test/save-modes.test.mjs deleted file mode 100644 index 657e9a8..0000000 --- a/plugins/io.github.muzimu217.session-import/test/save-modes.test.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/save-modes.js — the three distillation save combiners. - * Run: node --test test/save-modes.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const { combine, normalize, isMode, hasMergeSection, wrapSection, MERGE_START, MERGE_END } = require("../lib/save-modes.js"); - -describe("lib/save-modes combine", () => { - test("overwrite replaces existing entirely", () => { - assert.strictEqual(combine("OLD CONTENT", "NEW", "overwrite"), "NEW"); - }); - - test("overwrite is the fallback for unknown modes", () => { - assert.strictEqual(combine("OLD", "NEW", "bogus"), "NEW"); - assert.strictEqual(combine("OLD", "NEW", undefined), "NEW"); - }); - - test("append onto empty file == incoming", () => { - assert.strictEqual(combine("", "NEW", "append"), "NEW"); - }); - - test("append joins with a blank line separator", () => { - assert.strictEqual(combine("OLD", "NEW", "append"), "OLD\n\nNEW"); - }); - - test("merge on a fresh file wraps the incoming text in markers", () => { - const out = combine("", "DISTILLED", "merge"); - assert.strictEqual(out, `${MERGE_START}\nDISTILLED\n${MERGE_END}`); - assert.ok(hasMergeSection(out)); - }); - - test("merge replaces only the marked section, keeping surrounding prose", () => { - const existing = [ - "# Project conventions", - "", - MERGE_START, - "OLD DISTILLED", - MERGE_END, - "", - "## Notes", - "hand-written section", - ].join("\n"); - const out = combine(existing, "NEW DISTILLED", "merge"); - assert.ok(out.startsWith("# Project conventions")); - assert.ok(out.includes("## Notes")); - assert.ok(out.includes("hand-written section")); - assert.ok(out.includes("NEW DISTILLED")); - assert.ok(!out.includes("OLD DISTILLED")); - // exactly one marker pair remains - assert.strictEqual(out.split(MERGE_START).length - 1, 1); - assert.strictEqual(out.split(MERGE_END).length - 1, 1); - }); - - test("merge onto an unmarked file appends a wrapped section", () => { - const out = combine("# Heading\n\nprose", "DISTILLED", "merge"); - assert.ok(out.startsWith("# Heading")); - assert.ok(out.includes(wrapSection("DISTILLED"))); - }); -}); - -describe("lib/save-modes helpers", () => { - test("isMode recognizes only the three modes", () => { - assert.ok(isMode("overwrite") && isMode("append") && isMode("merge")); - assert.ok(!isMode("delete")); - }); - test("normalize maps unknown to overwrite", () => { - assert.strictEqual(normalize("merge"), "merge"); - assert.strictEqual(normalize("nope"), "overwrite"); - }); - test("hasMergeSection detects markers", () => { - assert.ok(hasMergeSection(wrapSection("x"))); - assert.ok(!hasMergeSection("no markers here")); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/util.test.mjs b/plugins/io.github.muzimu217.session-import/test/util.test.mjs deleted file mode 100644 index 5fb681b..0000000 --- a/plugins/io.github.muzimu217.session-import/test/util.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -// @ts-check -/** - * Unit tests for lib/util.js — toIso / truncateTitle / projectNameOf. - * These are the helpers every source adapter depends on for title/timestamp - * normalization. Edge cases here are common breaking points (null, undefined, - * epoch ms vs ISO string). - * - * Run: - * node --test test/util.test.mjs - */ -"use strict"; - -import { test, describe } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); -const { toIso, truncateTitle, projectNameOf } = require(`${PLUGIN_DIR}/lib/util.js`); - -describe("util.toIso", () => { - test("accepts epoch ms (number)", () => { - const ms = 1725840000000; // 2024-09-09T00:00:00Z - assert.strictEqual(toIso(ms), "2024-09-09T00:00:00.000Z"); - }); - - test("accepts ISO string passthrough", () => { - assert.strictEqual(toIso("2024-09-09T00:00:00.000Z"), "2024-09-09T00:00:00.000Z"); - }); - - test("falls back to fallback arg when invalid", () => { - const fb = "2024-01-01T00:00:00.000Z"; - assert.strictEqual(toIso("not-a-date", fb), fb); - }); - - test("falls back to now (ISO string) when invalid and no fallback", () => { - const r = toIso("not-a-date"); - assert.match(r, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - }); - - test("null and undefined each fall through to fallback", () => { - const fb = "2024-01-01T00:00:00.000Z"; - assert.strictEqual(toIso(null, fb), fb); - assert.strictEqual(toIso(undefined, fb), fb); - }); -}); - -describe("util.truncateTitle", () => { - test("returns empty for null / undefined / empty / whitespace", () => { - assert.strictEqual(truncateTitle(null), ""); - assert.strictEqual(truncateTitle(undefined), ""); - assert.strictEqual(truncateTitle(""), ""); - assert.strictEqual(truncateTitle(" \n "), ""); - }); - - test("collapses whitespace and trims before measuring length", () => { - assert.strictEqual(truncateTitle("hello\n\n\t world"), "hello world"); - }); - - test("passes through short titles", () => { - assert.strictEqual(truncateTitle("hello world"), "hello world"); - }); - - test("truncates at default 60 chars with ellipsis", () => { - const long = "a".repeat(100); - const out = truncateTitle(long); - assert.strictEqual(out.length, 61); // 60 + the ellipsis char - assert.match(out, /…$/); - }); - - test("respects custom max", () => { - const out = truncateTitle("abcdefgh", 5); - assert.strictEqual(out, "abcde…"); - }); - - test("does NOT truncate when length is exactly the max", () => { - const s = "x".repeat(60); - assert.strictEqual(truncateTitle(s), s); - }); -}); - -describe("util.projectNameOf", () => { - test("null / empty returns null", () => { - assert.strictEqual(projectNameOf(null), null); - assert.strictEqual(projectNameOf(""), null); - assert.strictEqual(projectNameOf(undefined), null); - }); - - test("returns last segment of unix path", () => { - assert.strictEqual(projectNameOf("/home/user/projects/foo"), "foo"); - }); - - test("returns last segment of windows path", () => { - assert.strictEqual(projectNameOf("C:\\Users\\me\\bar"), "bar"); - }); - - test("strips trailing separators", () => { - assert.strictEqual(projectNameOf("/tmp/proj/"), "proj"); - assert.strictEqual(projectNameOf("/tmp/proj///"), "proj"); - }); - - test("single-segment path returns that segment", () => { - assert.strictEqual(projectNameOf("proj"), "proj"); - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/test/workbuddy.test.mjs b/plugins/io.github.muzimu217.session-import/test/workbuddy.test.mjs deleted file mode 100644 index e8da257..0000000 --- a/plugins/io.github.muzimu217.session-import/test/workbuddy.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -// @ts-check -/** - * Integration tests for the WorkBuddy source adapter: - * - resolveResultText strips wrapper and reads the - * external file (only when within the .workbuddy/projects dir). - * - convertFile skips non-conversation lines (function_call, ai-title, …). - * - injection stripping works for paired and unpaired . - * - * Strategy: write a tiny jsonl fixture into a tmp dir, monkeypatch os.homedir - * via a unique path that the adapter computes from os.homedir() at call time. - * WorkBuddy's projectsDirFor defaults to os.homedir(); we instead invoke the - * helper directly with the tmp dir. - * - * Run: - * node --test test/workbuddy.test.mjs - */ -"use strict"; - -import { test, describe, before, after } from "node:test"; -import assert from "node:assert/strict"; -import { createRequire } from "node:module"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const require = createRequire(import.meta.url); -const PLUGIN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); -const wb = require(`${PLUGIN_DIR}/lib/sources/workbuddy.js`); - -let tmp; -before(async () => { tmp = await mkdtemp(path.join(os.tmpdir(), "workbuddy-test-")); }); -after(async () => { await rm(tmp, { recursive: true, force: true }); }); - -function jsonlLine(obj) { return JSON.stringify(obj); } - -describe("workbuddy.convertFile — integration with fixture", () => { - test("WorkBuddy adapter shape: source, label, scan, convert all exported", () => { - assert.strictEqual(wb.source, "workbuddy"); - assert.ok(wb.label.length > 0); - assert.strictEqual(typeof wb.scan, "function"); - assert.strictEqual(typeof wb.convert, "function"); - }); - - test("projectsDirFor() honors a custom home argument", () => { - assert.strictEqual(wb.projectsDirFor("/custom/home"), path.join("/custom/home", ".workbuddy", "projects")); - assert.strictEqual( - wb.projectsDirFor(), - path.join(os.homedir(), ".workbuddy", "projects"), - ); - }); - - test("scan() returns [] when ~/.workbuddy/projects doesn't exist (defensive)", async () => { - // Default os.homedir() is real; the projects dir likely doesn't have - // permission issues but scan tolerates any failure returning []. - // We assert the contract, not the contents. - const sessions = await wb.scan(); - assert.ok(Array.isArray(sessions), "scan returns an array"); - // If any sessions exist they must conform to the summary shape. - for (const s of sessions) { - assert.ok(s.source === "workbuddy"); - assert.ok(typeof s.externalId === "string"); - assert.ok(typeof s.title === "string"); - assert.ok(typeof s.fullTitle === "string"); - } - }); -}); - -describe("workbuddy injection stripping (defensive semantics)", () => { - // stripInjected lives in workbuddy.js but isn't exported. We exercise it - // through the convert path by embedding paired + unpaired - // blocks and reading the resulting message content. That covers the same - // code path without needing a back-door. - test("Paired block is stripped from user content", async () => { - const lines = [ - jsonlLine({ type: "message", role: "user", content: [{ type: "text", text: "real question" }], timestamp: "2024-09-09T00:00:00Z" }), - jsonlLine({ type: "message", role: "user", content: [{ - type: "text", - text: "injected junkactual q", - }], timestamp: "2024-09-09T00:00:01Z" }), - ].join("\n"); - const fixture = path.join(tmp, "strip-paired.jsonl"); - await writeFile(fixture, lines); - // We cannot invoke convertFile without going through convert(). - // We delegate: summarize() does blockText + stripInjected on firstUser. - // Not exported either — so we just sanity-check by textual observation: - // strip-paired is internally exercised by convert(). Use public scan() - // targeting a tmp HOME via process.env.HOME override is also infeasible - // without leaking. So we directly test the regex semantics by replicating - // the regexes from workbuddy.js here: - const INJECTED_BLOCK = /<(system-reminder|cb_summary|conversation_history_summary)\b[\s\S]*?<\/\1>/gi; - const USER_QUERY = /([\s\S]*?)<\/user_query>/i; - const input = "junkreal user content"; - const noInjected = input.replace(INJECTED_BLOCK, ""); - const m = noInjected.match(USER_QUERY); - if (m) { - // The workbuddy adapter returns query[1] — we just verify the regexes - // here as a regression guard for the same patterns it uses. - assert.strictEqual(m[1], "actual q"); - } else { - // If user_query absent, workbuddy returns the noInjected remainder - // stripped of any unclosed block — the regex shape is verified. - assert.match(noInjected, /real user content/); - assert.doesNotMatch(noInjected, /junk/); - } - void fixture; - }); -}); diff --git a/plugins/io.github.muzimu217.session-import/views/forge.html b/plugins/io.github.muzimu217.session-import/views/forge.html deleted file mode 100644 index b266cb8..0000000 --- a/plugins/io.github.muzimu217.session-import/views/forge.html +++ /dev/null @@ -1,454 +0,0 @@ - - - - - - - - Session Forge - - - -

会话熔炉

-

读回本插件导入的会话,用宿主额度蒸馏成项目约定与可复用做法。

- -
准备中…
- -
-
- - - - - - -
-
-
- -
-
- - -
-
- - - - -
-
- - - - - - - - - diff --git a/plugins/io.github.muzimu217.session-import/views/importer.html b/plugins/io.github.muzimu217.session-import/views/importer.html deleted file mode 100644 index 880ebb2..0000000 --- a/plugins/io.github.muzimu217.session-import/views/importer.html +++ /dev/null @@ -1,930 +0,0 @@ - - - - - - - - 一体化会话导入 - - - -
-
-

一体化会话导入

- 扫描本机安装的编程工具,选择来源后勾选会话导入 - - -
- -
-
-
- - -
-
-
-
-
-
-
-
- -
-
-
- - - -
-
-
- -
-
在左侧选择一个会话进行预览
- - -
-
- -
- 已选 0 / 0 - - -
-
- - - - - diff --git a/tests/panel-chrome.test.mjs b/tests/panel-chrome.test.mjs index 8a1b5a3..95380e3 100644 --- a/tests/panel-chrome.test.mjs +++ b/tests/panel-chrome.test.mjs @@ -45,7 +45,7 @@ test("every panel plugin documents the host-owned 46px chrome contract", () => { ); } - assert.equal(panelPlugins.length, 20, "the official marketplace should cover all panel plugins"); + assert.equal(panelPlugins.length, 19, "the official marketplace should cover all panel plugins"); }); test("super domain keeps its v3 surface full-bleed and interactive", () => { diff --git a/tests/session-import.test.mjs b/tests/session-import.test.mjs deleted file mode 100644 index 16d9dc2..0000000 --- a/tests/session-import.test.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const here = dirname(fileURLToPath(import.meta.url)); -const pluginRoot = join(here, "../plugins/io.github.muzimu217.session-import"); -const manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8")); - -test("session import manifest declares the exact runtime permissions", () => { - assert.equal(manifest.id, "io.github.muzimu217.session-import"); - assert.equal(manifest.version, "0.4.9"); - assert.deepEqual(manifest.permissions, [ - "ui.panel", - "ui.view", - "session.read", - "session.read.own", - "session.import", - "project.create", - "agent.complete", - "models.list", - "fs.read", - "fs.write", - ]); - assert.deepEqual( - manifest.contributes.sessionSources.map(({ id }) => id), - ["zcode", "workbuddy", "claude-code", "codex", "opencode", "pi"], - ); -});