gft-merge 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -32
- package/README_ZH.md +46 -33
- package/cli.mjs +6 -3
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- package/tools/claude.mjs +2 -1
- package/tools/codex.mjs +2 -1
- package/web/app.js +32 -13
- package/web/style.css +6 -0
package/README.md
CHANGED
|
@@ -2,65 +2,80 @@
|
|
|
2
2
|
|
|
3
3
|
# GFT-Merge
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Carry context between your AI coding sessions.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Pull sessions from Claude Code and Codex into a pool, then start a new session in either tool with that context already in it.**
|
|
8
|
+
|
|
9
|
+
You plan something in one Claude Code session, build it in Codex, and debug it in another session. Handing over means copying and pasting by hand. GFT-Merge reads your local sessions, lets you combine the ones that matter, and starts a fresh session whose first message carries them, so the AI picks up where you left off.
|
|
10
|
+
|
|
11
|
+
It runs on your computer and opens in your browser. A sibling of [GFT-Map](https://github.com/CoralLips/gft-map).
|
|
12
|
+
|
|
13
|
+
> The interface is in Chinese for now.
|
|
8
14
|
|
|
9
15
|
## Get started
|
|
10
16
|
|
|
11
|
-
You need Node.js 22
|
|
17
|
+
You'll need **Node.js 22+** and at least one of:
|
|
18
|
+
|
|
19
|
+
- **Claude Code** in the **Claude desktop app**, signed in. New Claude sessions are brought into the app through its own `claude://` link, so the app is required to see them.
|
|
20
|
+
- **Codex**, the Codex app or CLI, signed in. With the Codex app, new sessions also open there.
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
Then run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
14
25
|
npx gft-merge
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
Your browser opens `http://127.0.0.1:4319`.
|
|
28
|
+
Your browser opens `http://127.0.0.1:4319`. Run it again any time; it reuses the running service.
|
|
18
29
|
|
|
19
30
|
## How it works
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
The page has two halves. At the top, one column per tool lists your sessions from the last 30 days. Below, your pools on the left and the selected pool's workspace on the right. Everything is done by dragging.
|
|
33
|
+
|
|
34
|
+
| What you want | How |
|
|
35
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
36
|
+
| Put a session in a pool | Drag it from a tool's column into the workspace. It becomes a ball. |
|
|
37
|
+
| Combine sessions | Drop one ball on another. Any two balls can be fused. |
|
|
38
|
+
| Start a session with that context | Drag a ball up onto a tool's column. A new session starts there, named "merge · pool name". |
|
|
39
|
+
| Make a big ball smaller | Click the ball to compress it with a model (see below). |
|
|
40
|
+
| See what a ball holds | Hover the name under it. Drag a session out of that list to split it off. |
|
|
41
|
+
| Move or remove | Drop a ball on another pool in the list to move it; drop it outside the workspace to remove it. |
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
**The number in a ball** is its size in tokens. A single session shows that session's own context size, read from the tool's records. A fused or compressed ball shows what dragging it up would send, counted locally (marked ≈).
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
**Compression** sends the ball's content to the tool and model chosen under **Settings → 处理工具** (processing tool) and replaces it with the result. While it runs, the ball's ring turns and its name reads "压缩中…" (compressing). A compressed ball can't be split again; you can fuse more sessions onto it or compress it again. Drag it up before compression finishes and the original text is sent.
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
- **Claude Code**: waits for Claude's reply (from several seconds to a minute or so, depending on length), then imports and opens the session in the Claude app through its own `claude://resume` link.
|
|
37
|
-
- The new session opens in the project most of its sources came from.
|
|
47
|
+
**The new session's first message** contains the plain conversation of every session in the ball (your messages and the final answer of each turn, without tool output) and asks the AI to report briefly what it understood (what the sessions are about, where things stand, what is decided and open, what comes next), then wait for you. That reply is a real turn, so the session sits at the top of the tool's list. With Codex the page returns at once and the session opens when the reply ends; with Claude the page waits for the reply (several seconds to about a minute), then the session opens in the Claude app.
|
|
38
48
|
|
|
39
|
-
Why
|
|
49
|
+
**Why only new sessions:** Codex refuses other programs writing to a session that is open in the Codex app, and the Claude app shows its own copy of a session, not what another program writes into it. Neither is reliable, so GFT-Merge always starts a new session.
|
|
40
50
|
|
|
41
|
-
|
|
51
|
+
## Where does your data go?
|
|
42
52
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
53
|
+
- GFT-Merge reads your local Claude Code and Codex session files. It does not change them, and it does not install hooks or change either tool's settings.
|
|
54
|
+
- Pools are stored in `~/.gft-merge/` on your computer (set `GFT_MERGE_HOME` to move them).
|
|
55
|
+
- A model is called only in two cases, both started by you: dragging a ball up (the new session runs in that tool, on your account) and compressing (one request to the processing tool, on your account).
|
|
56
|
+
- No GFT account, no cloud.
|
|
46
57
|
|
|
47
58
|
## Commands
|
|
48
59
|
|
|
49
|
-
|
|
60
|
+
```sh
|
|
61
|
+
npx gft-merge # open the page, starting the service if needed
|
|
62
|
+
npx gft-merge stop # stop the service
|
|
63
|
+
npx gft-merge status # show whether it runs, and where the data is
|
|
64
|
+
```
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
Add `--port <number>` to use another port.
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
## Project status and feedback
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
- `pools/<id>.jsonl`: the pool's messages, one per line. When sessions are compressed their messages are removed and only the compact remains; the original sessions in each tool are untouched.
|
|
57
|
-
- `settings.json`: which tools are enabled, and the processor's tool and model.
|
|
70
|
+
Version 0.1, an early release. Tested on Windows; on macOS and Linux, opening sessions in the desktop apps is not verified yet. Supports Claude Code and Codex.
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
Share problems or ideas in [Issues](https://github.com/CoralLips/gft-merge/issues).
|
|
60
73
|
|
|
61
74
|
## Development
|
|
62
75
|
|
|
63
|
-
```
|
|
76
|
+
```sh
|
|
77
|
+
git clone https://github.com/CoralLips/gft-merge.git
|
|
78
|
+
cd gft-merge
|
|
64
79
|
npm install
|
|
65
80
|
npm test
|
|
66
81
|
node cli.mjs open
|
package/README_ZH.md
CHANGED
|
@@ -2,65 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
# GFT-Merge
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 让上下文在 AI 编程会话之间流动。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**把 Claude Code 和 Codex 的会话拉进一个池子,再在任一工具里开一个新会话,上下文已经带在里面。**
|
|
8
|
+
|
|
9
|
+
你在一个 Claude Code 会话里定方案,在 Codex 里写代码,又在另一个会话里排查问题。每次交接都得手动复制粘贴。GFT-Merge 读取你本机的会话,让你把有用的几段合在一起,然后开一个新会话、把它们作为第一条消息带进去,AI 就能接着往下干。
|
|
10
|
+
|
|
11
|
+
在你自己的电脑上运行,在浏览器里打开。和 [GFT-Map](https://github.com/CoralLips/gft-map) 是同一系列的工具。
|
|
8
12
|
|
|
9
13
|
## 开始
|
|
10
14
|
|
|
11
|
-
需要 Node.js 22
|
|
15
|
+
需要 **Node.js 22 以上**,以及下面至少一个:
|
|
16
|
+
|
|
17
|
+
- **Claude 桌面应用里的 Claude Code**,已登录。新建的 Claude 会话要通过桌面应用自己的 `claude://` 链接导入,所以必须有桌面应用才能看到。
|
|
18
|
+
- **Codex**,桌面应用或命令行都可以,已登录。装了 Codex 桌面应用的话,新会话也会在里面打开。
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
然后运行:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
14
23
|
npx gft-merge
|
|
15
24
|
```
|
|
16
25
|
|
|
17
|
-
浏览器会打开 `http://127.0.0.1:4319
|
|
26
|
+
浏览器会打开 `http://127.0.0.1:4319`。之后随时再运行一次即可,已经在跑的服务会直接复用。
|
|
27
|
+
|
|
28
|
+
## 怎么用
|
|
18
29
|
|
|
19
|
-
|
|
30
|
+
页面分上下两半。上面每个工具一栏,列出最近 30 天的会话;下面左边是池子列表,右边是选中池子的工作区。所有操作都靠拖。
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- **移动和移出**:把球拖到左边另一个池子就搬过去;拖出工作区就移出池子。
|
|
32
|
+
| 想做什么 | 怎么做 |
|
|
33
|
+
| ---------------------- | -------------------------------------------------------------------- |
|
|
34
|
+
| 把会话放进池子 | 从上面的工具栏拖进工作区,它就成了一个球 |
|
|
35
|
+
| 合并几个会话 | 把一个球拖到另一个球上,任意两个球都能融合 |
|
|
36
|
+
| 带着这些上下文开新会话 | 把球往上拖到某个工具的栏上,就在那里新建会话,名为「merge · 池子名」 |
|
|
37
|
+
| 把大球变小 | 点一下球,用模型压缩(见下文) |
|
|
38
|
+
| 看球里有什么 | 鼠标悬停在球下方的名字上;从列表里把某个会话拖出去就能拆开 |
|
|
39
|
+
| 搬走或移出 | 把球拖到左边另一个池子上就搬过去;拖出工作区就移出池子 |
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
**球里的数字**是 token 数。单个会话的球显示这个会话本身的上下文大小,直接读自工具的记录;融合或压缩过的球显示往上拖时要送出去的量,由本地分词器数出来,前面带 ≈。
|
|
32
42
|
|
|
33
|
-
|
|
43
|
+
**压缩**会把球的内容发给 **设置 → 处理工具** 里选的工具和模型,用结果替换原内容。压缩中球的虚线圈会转,名字处显示“压缩中…”。压完的球不能再拆,但可以继续往上融合新的会话,也可以再压。压缩没完成时往上拖,送出去的是原文。
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
- **Claude Code**:等 Claude 回完(几秒到一分钟左右,看内容长短),再用桌面端自己的 `claude://resume` 链接把会话导入桌面端并打开。
|
|
37
|
-
- 新会话放在来源会话最多的那个项目目录。
|
|
45
|
+
**新会话的第一条消息**包含球里每个会话的原话(你说的话和 AI 每轮的最终回答,不含工具输出),并请 AI 先简要汇报它的理解(这些会话在做什么、进展到哪、定了什么、没定什么、下一步做什么),然后等你的指示。这一轮是真对话,所以新会话排在工具列表最上面。Codex 那边页面立刻返回,回复结束后在 Codex 里打开;Claude 那边要等回复结束(几秒到一分钟左右),然后在 Claude 桌面应用里打开。
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
**为什么只做新建:** Codex 会话在 Codex 桌面应用里开着时,Codex 不让别的程序往里写;Claude 桌面应用显示的是它自己保存的会话内容,别的程序写进去的消息它不显示。两条路都靠不住,所以 GFT-Merge 只新建会话。
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
## 数据去哪了
|
|
42
50
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
51
|
+
- GFT-Merge 读取你本机 Claude Code 和 Codex 的会话文件,不修改它们,也不装钩子、不改两家工具的任何设置。
|
|
52
|
+
- 池子存在你电脑上的 `~/.gft-merge/`(可用 `GFT_MERGE_HOME` 改位置)。
|
|
53
|
+
- 只有两种情况会调用模型,都由你触发:往上拖新建会话(在那个工具里、用你的账号额度),以及压缩(向处理工具发一次请求,用你的账号额度)。
|
|
54
|
+
- 不需要 GFT 账号,不上云。
|
|
46
55
|
|
|
47
56
|
## 命令
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
```sh
|
|
59
|
+
npx gft-merge # 打开页面,需要时自动启动服务
|
|
60
|
+
npx gft-merge stop # 停止服务
|
|
61
|
+
npx gft-merge status # 查看是否在运行、数据在哪
|
|
62
|
+
```
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
加 `--port <端口>` 可以换端口。
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
## 项目状态和反馈
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
- `pools/<id>.jsonl`:池子里的消息,一行一条。被压缩的会话,原消息随之移除,只留压缩块;原会话本身在各工具里不受影响。
|
|
57
|
-
- `settings.json`:启用哪些工具、处理工具用哪个工具的哪个模型。
|
|
68
|
+
0.1 版,早期版本。在 Windows 上实测过;macOS 和 Linux 上在桌面应用里打开会话还没验证。支持 Claude Code 和 Codex。
|
|
58
69
|
|
|
59
|
-
|
|
70
|
+
问题和想法欢迎提在 [Issues](https://github.com/CoralLips/gft-merge/issues)。
|
|
60
71
|
|
|
61
72
|
## 开发
|
|
62
73
|
|
|
63
|
-
```
|
|
74
|
+
```sh
|
|
75
|
+
git clone https://github.com/CoralLips/gft-merge.git
|
|
76
|
+
cd gft-merge
|
|
64
77
|
npm install
|
|
65
78
|
npm test
|
|
66
79
|
node cli.mjs open
|
package/cli.mjs
CHANGED
|
@@ -8,16 +8,19 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { homeDir, readJson, writeJson } from './home.mjs';
|
|
9
9
|
import { DEFAULT_PORT, startServer } from './server.mjs';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// The command may be left out, as in `gft-merge --port 4329`: then it is `open`.
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const command = args[0] && !args[0].startsWith('--') ? args[0] : 'open';
|
|
14
|
+
const portAt = args.indexOf('--port');
|
|
12
15
|
const port =
|
|
13
|
-
Number(
|
|
16
|
+
(portAt >= 0 && Number(args[portAt + 1])) || Number(process.env.GFT_MERGE_PORT) || DEFAULT_PORT;
|
|
14
17
|
const url = `http://127.0.0.1:${port}`;
|
|
15
18
|
const serviceFile = path.join(homeDir(), 'service.json');
|
|
16
19
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
17
20
|
|
|
18
21
|
const portTaken = () =>
|
|
19
22
|
new Error(
|
|
20
|
-
`端口 ${port} 已被其他程序占用,GFT-Merge 没有启动。请换一个端口,例如:
|
|
23
|
+
`端口 ${port} 已被其他程序占用,GFT-Merge 没有启动。请换一个端口,例如:npx gft-merge --port ${port + 10}`,
|
|
21
24
|
);
|
|
22
25
|
|
|
23
26
|
/** Whether anything listens on the port, without speaking HTTP to it. */
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
package/tools/claude.mjs
CHANGED
|
@@ -252,8 +252,9 @@ export function createClaudeTool({
|
|
|
252
252
|
sdk = defaultSdk,
|
|
253
253
|
desktopDir = defaultDesktopDir(),
|
|
254
254
|
projectsDir = defaultProjectsDir(),
|
|
255
|
-
open = openLink,
|
|
256
255
|
executable = appClaudeCode(),
|
|
256
|
+
// Without the Claude app nothing handles claude:// links; opening one would only raise an error.
|
|
257
|
+
open = executable ? openLink : () => {},
|
|
257
258
|
} = {}) {
|
|
258
259
|
// Every Claude Code this tool starts runs without the parent session's
|
|
259
260
|
// variables, and is the Claude app's own copy when there is one.
|
package/tools/codex.mjs
CHANGED
|
@@ -218,7 +218,8 @@ const TURN_MS = 15 * 60 * 1000;
|
|
|
218
218
|
export function createCodexTool({
|
|
219
219
|
connect = connectAppServer,
|
|
220
220
|
retryMs = 500,
|
|
221
|
-
|
|
221
|
+
// Without the Codex app nothing handles codex:// links; opening one would only raise an error.
|
|
222
|
+
open = appCodex() || existsSync('/Applications/Codex.app') ? openLink : () => {},
|
|
222
223
|
} = {}) {
|
|
223
224
|
// One app-server starts at a time from here: two starting together clash over
|
|
224
225
|
// Codex's state database. A clash with the Codex app starting up is retried once.
|
package/web/app.js
CHANGED
|
@@ -22,6 +22,7 @@ const state = {
|
|
|
22
22
|
sessions: {}, // tool id → listed sessions
|
|
23
23
|
processors: null, // tools that can compress, with their models; read when the menu first opens
|
|
24
24
|
busy: new Set(), // tool ids starting a session now
|
|
25
|
+
unavailable: {}, // tool id → why its sessions could not be read
|
|
25
26
|
fresh: null, // key of the session just started, shown with a brief highlight
|
|
26
27
|
compressing: new Set(), // ball ids this page asked to compress
|
|
27
28
|
processor: null, // { tool, model } that compresses
|
|
@@ -117,7 +118,7 @@ function renderTools() {
|
|
|
117
118
|
state.busy.has(tool.id) ? el('span', { className: 'spinner' }) : null,
|
|
118
119
|
el('span', {
|
|
119
120
|
className: 'count',
|
|
120
|
-
textContent: loaded ? `${sessions.length} 个会话` : '',
|
|
121
|
+
textContent: loaded && !state.unavailable[tool.id] ? `${sessions.length} 个会话` : '',
|
|
121
122
|
}),
|
|
122
123
|
);
|
|
123
124
|
const rows = sessions.map(item =>
|
|
@@ -149,7 +150,10 @@ function renderTools() {
|
|
|
149
150
|
{},
|
|
150
151
|
rows.length
|
|
151
152
|
? rows
|
|
152
|
-
: el('li', {
|
|
153
|
+
: el('li', {
|
|
154
|
+
className: 'empty',
|
|
155
|
+
textContent: !loaded ? '读取中…' : state.unavailable[tool.id] || '最近没有会话',
|
|
156
|
+
}),
|
|
153
157
|
),
|
|
154
158
|
);
|
|
155
159
|
}),
|
|
@@ -762,14 +766,24 @@ function renderList() {
|
|
|
762
766
|
title: '改名',
|
|
763
767
|
onclick: e => {
|
|
764
768
|
e.stopPropagation();
|
|
765
|
-
|
|
766
|
-
name.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
769
|
+
// The name itself becomes editable in place: Enter keeps it, Escape or an empty name drops it.
|
|
770
|
+
name.contentEditable = 'plaintext-only';
|
|
771
|
+
name.focus();
|
|
772
|
+
getSelection().selectAllChildren(name);
|
|
773
|
+
name.onclick = k => k.stopPropagation();
|
|
774
|
+
name.onkeydown = k => {
|
|
775
|
+
if (k.key === 'Enter') {
|
|
776
|
+
k.preventDefault();
|
|
777
|
+
name.blur();
|
|
778
|
+
}
|
|
779
|
+
if (k.key === 'Escape') {
|
|
780
|
+
name.textContent = pool.name;
|
|
781
|
+
name.blur();
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
name.onblur = () => {
|
|
785
|
+
name.contentEditable = 'false';
|
|
786
|
+
const next = name.textContent.trim();
|
|
773
787
|
if (!next || next === pool.name) return render();
|
|
774
788
|
pool.name = next;
|
|
775
789
|
render();
|
|
@@ -906,12 +920,14 @@ let poolsSeen = '';
|
|
|
906
920
|
async function loadPools({ quiet = false } = {}) {
|
|
907
921
|
const pools = await api('GET', '/api/pools');
|
|
908
922
|
const seen = JSON.stringify(pools);
|
|
909
|
-
|
|
923
|
+
// Unchanged pools are kept as they are: the page's handlers hold these very
|
|
924
|
+
// objects, and a fresh copy would make their changes land on stale ones.
|
|
925
|
+
if (quiet && seen === poolsSeen) return;
|
|
910
926
|
poolsSeen = seen;
|
|
911
927
|
state.pools = pools;
|
|
912
928
|
if (!state.pools.some(pool => pool.id === state.selectedId))
|
|
913
929
|
state.selectedId = state.pools[0]?.id ?? null;
|
|
914
|
-
if (
|
|
930
|
+
if (!state.dragging) render();
|
|
915
931
|
}
|
|
916
932
|
|
|
917
933
|
async function loadSessions() {
|
|
@@ -919,11 +935,14 @@ async function loadSessions() {
|
|
|
919
935
|
state.tools
|
|
920
936
|
.filter(tool => state.enabled[tool.id] !== false)
|
|
921
937
|
.map(async tool => {
|
|
938
|
+
// A tool that can't be read (not installed, say) says so in its own column, without a popup each time.
|
|
922
939
|
try {
|
|
923
940
|
state.sessions[tool.id] = await api('GET', `/api/sessions?tool=${tool.id}`);
|
|
941
|
+
delete state.unavailable[tool.id];
|
|
924
942
|
} catch (error) {
|
|
925
943
|
state.sessions[tool.id] = [];
|
|
926
|
-
|
|
944
|
+
state.unavailable[tool.id] =
|
|
945
|
+
`${error.message}不用这个工具的话,可以在右上角设置里关掉这一栏。`;
|
|
927
946
|
}
|
|
928
947
|
}),
|
|
929
948
|
);
|
package/web/style.css
CHANGED
|
@@ -308,6 +308,12 @@ body.carrying-up .bucket::after {
|
|
|
308
308
|
text-overflow: ellipsis;
|
|
309
309
|
white-space: nowrap;
|
|
310
310
|
}
|
|
311
|
+
.pool-item .name[contenteditable='plaintext-only'] {
|
|
312
|
+
outline: none;
|
|
313
|
+
cursor: text;
|
|
314
|
+
text-overflow: clip;
|
|
315
|
+
border-bottom: 1px solid var(--accent);
|
|
316
|
+
}
|
|
311
317
|
.pool-item .edit {
|
|
312
318
|
visibility: hidden;
|
|
313
319
|
border: none;
|