@bolloon/bolloon-agent 0.1.41 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.comm/README.md +21 -0
  2. package/.comm/default/2026-06-17T08-23-00-017Z-8a735de8.md +7 -0
  3. package/CLAUDE.md +3 -0
  4. package/build/icon.icns +0 -0
  5. package/build/icon.ico +0 -0
  6. package/build/icon.png +0 -0
  7. package/build/tray.png +0 -0
  8. package/build/trayTemplate.png +0 -0
  9. package/dist/agents/intent-classifier.js +99 -0
  10. package/dist/agents/pi-sdk.js +1418 -67
  11. package/dist/agents/pre-tool-validator.js +2 -1
  12. package/dist/agents/shell-guard.js +9 -3
  13. package/dist/agents/shell-tool.js +28 -13
  14. package/dist/agents/task-state.js +224 -0
  15. package/dist/agents/workflow-pivot-loop.js +30 -0
  16. package/dist/bollharness-integration/gate-state-machine.js +13 -0
  17. package/dist/bollharness-integration/integration.js +71 -0
  18. package/dist/bollharness-integration/skill-adapter.js +52 -127
  19. package/dist/bootstrap/context-hierarchy.js +6 -4
  20. package/dist/cli/interface.js +28 -0
  21. package/dist/documents/reader.js +14 -0
  22. package/dist/electron/config.js +21 -0
  23. package/dist/electron/dialogs.js +108 -0
  24. package/dist/electron/first-run.js +170 -0
  25. package/dist/electron/ipc.js +20 -0
  26. package/dist/electron/logger.js +114 -0
  27. package/dist/electron/main.js +63 -0
  28. package/dist/electron/menu.js +145 -0
  29. package/dist/electron/paths.js +75 -0
  30. package/dist/electron/server.js +112 -0
  31. package/dist/electron/tray.js +111 -0
  32. package/dist/electron/window.js +108 -0
  33. package/dist/git-transport/chat-render.js +155 -0
  34. package/dist/git-transport/chat-repo.js +370 -0
  35. package/dist/git-transport/chat-types.js +23 -0
  36. package/dist/git-transport/chat-watch.js +102 -0
  37. package/dist/index.js +477 -28
  38. package/dist/llm/pi-ai.js +103 -27
  39. package/dist/network/local-inbox-bus.js +73 -0
  40. package/dist/network/p2p-direct.js +3 -4
  41. package/dist/pi-ecosystem-judgment/adaptive-scan.js +6 -2
  42. package/dist/pi-ecosystem-judgment/causal-judge.js +3 -3
  43. package/dist/pi-ecosystem-judgment/index.js +1 -1
  44. package/dist/security/tool-gate.js +11 -0
  45. package/dist/web/server.js +68 -4
  46. package/package.json +8 -3
  47. package/scripts/lefthook-helper.sh +69 -0
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getMainWindow = getMainWindow;
37
+ exports.createMainWindow = createMainWindow;
38
+ exports.focusMainWindow = focusMainWindow;
39
+ /**
40
+ * 主窗口工厂 — preload 路径解析 + dev/prod loadURL + 外部链接走系统浏览器
41
+ */
42
+ const electron_1 = require("electron");
43
+ const path = __importStar(require("path"));
44
+ const logger_1 = require("./logger");
45
+ const config_1 = require("./config");
46
+ const server_1 = require("./server");
47
+ let mainWindow = null;
48
+ function getMainWindow() {
49
+ return mainWindow;
50
+ }
51
+ async function createMainWindow() {
52
+ (0, logger_1.log)('创建主窗口...');
53
+ mainWindow = new electron_1.BrowserWindow({
54
+ width: config_1.MAIN_WINDOW_DEFAULT.width,
55
+ height: config_1.MAIN_WINDOW_DEFAULT.height,
56
+ minWidth: config_1.MAIN_WINDOW_MIN.width,
57
+ minHeight: config_1.MAIN_WINDOW_MIN.height,
58
+ title: 'Bolloon Agent',
59
+ webPreferences: {
60
+ nodeIntegration: false,
61
+ contextIsolation: true,
62
+ sandbox: false, // MCP/spawn 等需要 fs; 真正的隔离靠 preload 边界
63
+ preload: path.join(__dirname, '..', 'electron-preload.js'),
64
+ },
65
+ show: false,
66
+ });
67
+ if (config_1.isDev) {
68
+ // dev:web 用 tsx 起 server, 端口固定 preferredPort, 不会 EADDRINUSE 自增
69
+ const port = (0, config_1.preferredPort)();
70
+ mainWindow.loadURL(`http://localhost:${port}`);
71
+ mainWindow.webContents.openDevTools();
72
+ }
73
+ else {
74
+ try {
75
+ (0, logger_1.log)('启动内置 Web 服务器...');
76
+ const { port: actualPort } = await (0, server_1.startWebServer)((0, config_1.preferredPort)());
77
+ mainWindow.loadURL(`http://localhost:${actualPort}`);
78
+ }
79
+ catch (err) {
80
+ (0, logger_1.log)(`启动服务器失败: ${err.message}`, 'error');
81
+ console.error('启动服务器失败:', err);
82
+ }
83
+ }
84
+ mainWindow.once('ready-to-show', () => {
85
+ mainWindow?.show();
86
+ (0, logger_1.log)('窗口已显示');
87
+ });
88
+ // 外部链接走系统浏览器, 不在 app 内开新窗口
89
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
90
+ if (url.startsWith('http://') || url.startsWith('https://')) {
91
+ electron_1.shell.openExternal(url);
92
+ }
93
+ return { action: 'deny' };
94
+ });
95
+ mainWindow.on('closed', () => {
96
+ mainWindow = null;
97
+ });
98
+ (0, logger_1.log)('窗口创建完成');
99
+ }
100
+ function focusMainWindow() {
101
+ if (!mainWindow)
102
+ return;
103
+ if (mainWindow.isMinimized())
104
+ mainWindow.restore();
105
+ mainWindow.show();
106
+ mainWindow.focus();
107
+ }
108
+ //# sourceMappingURL=window.js.map
@@ -0,0 +1,155 @@
1
+ // src/git-transport/chat-render.ts
2
+ // 解析 markdown 消息文件 + 去重 key + 列表/过滤, 纯函数, 不碰 git.
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as crypto from 'crypto';
6
+ import { CHAT_PROTOCOL_VERSION } from './chat-types.js';
7
+ function tryRead(p) {
8
+ try {
9
+ return fs.readFileSync(p, 'utf8');
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ // 极简 frontmatter 解析: 不引 gray-matter 这种依赖
16
+ // 约定: 文件首行是 "---", 之后是 YAML-like 块 (key: value), 直到下一个 "---"
17
+ // 字段值只支持 string / number / 嵌套对象字面量; 对我们够用
18
+ function parseFrontmatter(raw) {
19
+ if (!raw.startsWith('---'))
20
+ return { fm: null, body: raw };
21
+ const lines = raw.split(/\r?\n/);
22
+ let end = -1;
23
+ for (let i = 1; i < lines.length; i++) {
24
+ if (lines[i].trim() === '---') {
25
+ end = i;
26
+ break;
27
+ }
28
+ }
29
+ if (end === -1)
30
+ return { fm: null, body: raw };
31
+ const headerLines = lines.slice(1, end);
32
+ const body = lines.slice(end + 1).join('\n').replace(/^\n+/, '');
33
+ const fm = { v: CHAT_PROTOCOL_VERSION };
34
+ let i = 0;
35
+ while (i < headerLines.length) {
36
+ const line = headerLines[i];
37
+ if (!line.trim() || line.trim().startsWith('#')) {
38
+ i++;
39
+ continue;
40
+ }
41
+ const m = /^([A-Za-z_][\w]*)\s*:\s*(.*)$/.exec(line);
42
+ if (!m) {
43
+ i++;
44
+ continue;
45
+ }
46
+ const key = m[1];
47
+ let val = m[2].trim();
48
+ if (val === '') {
49
+ // 嵌套对象 — 收集到下一个顶级 key 之前
50
+ const obj = {};
51
+ i++;
52
+ while (i < headerLines.length) {
53
+ const l2 = headerLines[i];
54
+ if (/^[A-Za-z_][\w]*\s*:/.test(l2))
55
+ break;
56
+ const m2 = /^(\s+)([A-Za-z_][\w]*)\s*:\s*(.*)$/.exec(l2);
57
+ if (m2) {
58
+ obj[m2[2]] = stripQuotes(m2[3].trim());
59
+ }
60
+ i++;
61
+ }
62
+ val = obj;
63
+ }
64
+ else {
65
+ val = stripQuotes(val);
66
+ // 数字字面量尽量 parse 成 number, 让 fm.v 严格比较能过
67
+ if (/^-?\d+(\.\d+)?$/.test(val)) {
68
+ const n = Number(val);
69
+ if (!Number.isNaN(n))
70
+ val = n;
71
+ }
72
+ i++;
73
+ }
74
+ fm[key] = val;
75
+ }
76
+ // 强校验
77
+ if (typeof fm.from !== 'string' || typeof fm.fromPk !== 'string' || typeof fm.ts !== 'string') {
78
+ return { fm: null, body: raw };
79
+ }
80
+ if (fm.v !== CHAT_PROTOCOL_VERSION) {
81
+ return { fm: null, body: raw };
82
+ }
83
+ return { fm: fm, body };
84
+ }
85
+ function stripQuotes(s) {
86
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
87
+ return s.slice(1, -1);
88
+ }
89
+ return s;
90
+ }
91
+ export function parseMessageFile(filePath, sha) {
92
+ const raw = tryRead(filePath);
93
+ if (raw === null)
94
+ return null;
95
+ const { fm, body } = parseFrontmatter(raw);
96
+ if (!fm)
97
+ return null;
98
+ return { filePath, frontmatter: fm, body, sha };
99
+ }
100
+ // 列出 .comm/<role>/*.md 下所有消息文件 (角色目录是按字母扫的, 不依赖 git)
101
+ export function listMessageFiles(repoDir) {
102
+ const comm = path.join(repoDir, '.comm');
103
+ let entries = [];
104
+ try {
105
+ entries = fs.readdirSync(comm, { withFileTypes: true });
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const out = [];
111
+ for (const e of entries) {
112
+ if (!e.isDirectory())
113
+ continue;
114
+ if (e.name.startsWith('_'))
115
+ continue; // _state / _inbox 跳过
116
+ const roleDir = path.join(comm, e.name);
117
+ let files = [];
118
+ try {
119
+ files = fs.readdirSync(roleDir);
120
+ }
121
+ catch {
122
+ continue;
123
+ }
124
+ for (const f of files) {
125
+ if (!f.endsWith('.md'))
126
+ continue;
127
+ out.push(path.join(roleDir, f));
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ export function listMessages(repoDir, withRole, limit) {
133
+ const files = listMessageFiles(repoDir);
134
+ const out = [];
135
+ for (const f of files) {
136
+ if (withRole && !f.includes(`${path.sep}.comm${path.sep}${withRole}${path.sep}`))
137
+ continue;
138
+ const msg = parseMessageFile(f, '');
139
+ if (msg)
140
+ out.push(msg);
141
+ }
142
+ out.sort((a, b) => a.frontmatter.ts.localeCompare(b.frontmatter.ts));
143
+ return typeof limit === 'number' ? out.slice(-limit) : out;
144
+ }
145
+ // 去重 key: (fromPk, ts, contentHash8) — 不论 P2P 还是 git 谁先到都收敛到同一键
146
+ export function dedupeKey(msg) {
147
+ const h = crypto.createHash('sha256').update(msg.body).digest('hex').slice(0, 8);
148
+ return `${msg.frontmatter.fromPk}:${msg.frontmatter.ts}:${h}`;
149
+ }
150
+ // 单行展示 — 给 chat-list / chat-watch 输出用
151
+ export function renderOneLine(msg) {
152
+ const time = msg.frontmatter.ts.replace('T', ' ').replace(/\.\d+Z$/, 'Z').replace('Z', '');
153
+ const preview = msg.body.split('\n')[0].slice(0, 80);
154
+ return `[${time} ${msg.frontmatter.from}] ${preview}`;
155
+ }
@@ -0,0 +1,370 @@
1
+ // src/git-transport/chat-repo.ts
2
+ // 负责 .comm/ 目录初始化、消息写盘、commit/pull/push、状态持久化.
3
+ // 全部用 child_process.spawn('git', ...) — 不引 simple-git / nodegit.
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as crypto from 'crypto';
7
+ import { spawn } from 'child_process';
8
+ import { chatPaths, CHAT_PROTOCOL_VERSION, CHAT_BODY_MAX_BYTES, COMMIT_MESSAGE_MAX, CHAT_P2P_NOTIFY_THRESHOLD, } from './chat-types.js';
9
+ import { parseMessageFile, dedupeKey, listMessageFiles } from './chat-render.js';
10
+ // ---------- helpers ----------
11
+ function runGit(args, opts) {
12
+ return new Promise((resolve) => {
13
+ const child = spawn('git', args, { cwd: opts.cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
14
+ let stdout = '';
15
+ let stderr = '';
16
+ const timer = setTimeout(() => {
17
+ try {
18
+ child.kill('SIGKILL');
19
+ }
20
+ catch { }
21
+ resolve({ stdout, stderr: stderr + '\n[killed: timeout]', code: 124 });
22
+ }, opts.timeoutMs ?? 30_000);
23
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
24
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
25
+ child.on('close', (code) => {
26
+ clearTimeout(timer);
27
+ resolve({ stdout, stderr, code: code ?? 1 });
28
+ });
29
+ child.on('error', (err) => {
30
+ clearTimeout(timer);
31
+ resolve({ stdout, stderr: stderr + '\n' + err.message, code: 127 });
32
+ });
33
+ });
34
+ }
35
+ async function mkdirp(p) {
36
+ await fs.promises.mkdir(p, { recursive: true });
37
+ }
38
+ // 原子获取 lock — 用 mkdir 互斥, 失败重试最多 30s
39
+ async function acquireLock(lockPath, timeoutMs = 30_000) {
40
+ const start = Date.now();
41
+ while (true) {
42
+ try {
43
+ await fs.promises.mkdir(lockPath);
44
+ break;
45
+ }
46
+ catch (e) {
47
+ if (e.code !== 'EEXIST')
48
+ throw e;
49
+ // 锁超过 60s 视为僵尸, 强行清掉
50
+ try {
51
+ const st = await fs.promises.stat(lockPath);
52
+ if (Date.now() - st.mtimeMs > 60_000) {
53
+ await fs.promises.rmdir(lockPath).catch(() => { });
54
+ }
55
+ }
56
+ catch { }
57
+ if (Date.now() - start > timeoutMs) {
58
+ throw new Error(`acquire git lock timeout: ${lockPath}`);
59
+ }
60
+ await new Promise((r) => setTimeout(r, 200));
61
+ }
62
+ }
63
+ return async () => {
64
+ try {
65
+ await fs.promises.rmdir(lockPath);
66
+ }
67
+ catch { }
68
+ };
69
+ }
70
+ function readJsonSafe(p, fallback) {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ }
74
+ catch {
75
+ return fallback;
76
+ }
77
+ }
78
+ async function writeJsonSafe(p, v) {
79
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
80
+ await fs.promises.writeFile(p, JSON.stringify(v, null, 2) + '\n', 'utf8');
81
+ }
82
+ // ---------- identity ----------
83
+ import { loadOrCreateKeyPair } from '../network/p2p-secret.js';
84
+ export async function resolveIdentity(roleOverride) {
85
+ const role = roleOverride || process.env.BOLLOON_ROLE || process.env.IROH_ROLE || 'default';
86
+ // loadOrCreateKeyPair 读 ~/.bolloon/p2p-direct-secret-{role}.json
87
+ const kp = await loadOrCreateKeyPair(role);
88
+ // 关键: 让 git author 反映 Ed25519 公钥, 这样 git log 一眼能认出"谁写的"
89
+ return {
90
+ role,
91
+ publicKey: kp.publicKey,
92
+ name: role,
93
+ email: `${kp.publicKey.slice(0, 12)}@bolloon.local`,
94
+ };
95
+ }
96
+ export async function chatInit(repoDir, opts = {}) {
97
+ const id = await resolveIdentity(opts.roleOverride);
98
+ const paths = chatPaths(repoDir);
99
+ await mkdirp(path.join(paths.root, id.role));
100
+ await mkdirp(paths.stateDir);
101
+ await mkdirp(paths.inboxDir);
102
+ // README.md
103
+ const readme = [
104
+ '# .comm/ — 跨机聊天收件箱',
105
+ '',
106
+ 'Bolloon chat transport: commits-as-messages.',
107
+ '每条消息 = 一个 markdown 文件 + 一次 git commit + push.',
108
+ '',
109
+ '## 目录约定',
110
+ '',
111
+ '- `<role>/` — 每个 role 一个子目录, 里面是该角色发的所有消息',
112
+ '- `_state/` — 本地运行态 (cursor, seen, lock), 不 commit',
113
+ '- `_inbox/` — 看门狗把对方消息反写到本地, 不 commit',
114
+ '',
115
+ '## 子命令',
116
+ '',
117
+ '```',
118
+ 'bolloon --chat-init',
119
+ 'bolloon --chat-send "..."',
120
+ 'bolloon --chat-pull',
121
+ 'bolloon --chat-list',
122
+ 'bolloon --chat-watch',
123
+ 'bolloon --chat-status',
124
+ '```',
125
+ '',
126
+ ].join('\n');
127
+ await fs.promises.writeFile(paths.readmeFile, readme, 'utf8');
128
+ // REMOTE 文件
129
+ if (opts.remote) {
130
+ await fs.promises.writeFile(paths.remoteFile, opts.remote.trim() + '\n', 'utf8');
131
+ }
132
+ // 探测 remote / branch
133
+ const remote = opts.remote || (await runGit(['remote', 'get-url', 'origin'], { cwd: repoDir })).stdout.trim() || undefined;
134
+ const branch = opts.branch || (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).stdout.trim() || 'master';
135
+ // 配 git user.name/user.email
136
+ await runGit(['config', 'user.name', id.name], { cwd: repoDir });
137
+ await runGit(['config', 'user.email', id.email], { cwd: repoDir });
138
+ // 首次 rebase
139
+ const pull = await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
140
+ // 把 README + REMOTE 第一次 commit 上去 (若有变更)
141
+ await runGit(['add', '.comm/README.md'], { cwd: repoDir });
142
+ if (fs.existsSync(paths.remoteFile)) {
143
+ await runGit(['add', '.comm/REMOTE'], { cwd: repoDir });
144
+ }
145
+ const diff = await runGit(['diff', '--cached', '--name-only'], { cwd: repoDir });
146
+ const messages = [];
147
+ if (diff.stdout.trim()) {
148
+ const msg = `[chat-init] role=${id.role} pk=${id.publicKey.slice(0, 12)}`;
149
+ const commit = await runGit(['commit', '-m', msg], { cwd: repoDir });
150
+ if (commit.code === 0) {
151
+ messages.push(`created initial commit: ${msg}`);
152
+ if (remote) {
153
+ const push = await runGit(['push', remote, branch], { cwd: repoDir });
154
+ if (push.code === 0)
155
+ messages.push(`pushed to ${remote}/${branch}`);
156
+ else
157
+ messages.push(`push failed (will retry on next chat-send): ${push.stderr.trim().split('\n').slice(-1)[0]}`);
158
+ }
159
+ else {
160
+ messages.push('no remote configured, will only commit locally');
161
+ }
162
+ }
163
+ }
164
+ else {
165
+ messages.push('.comm/ already initialized, no changes');
166
+ }
167
+ return {
168
+ ok: true,
169
+ role: id.role,
170
+ publicKey: id.publicKey,
171
+ remote,
172
+ branch,
173
+ messages: [
174
+ `role: ${id.role}`,
175
+ `publicKey: ${id.publicKey.slice(0, 16)}...`,
176
+ ...(remote ? [`remote: ${remote}`] : []),
177
+ ...(branch ? [`branch: ${branch}`] : []),
178
+ ...messages,
179
+ ...(pull.code !== 0 ? [`pull warning: ${pull.stderr.trim().split('\n').slice(-1)[0] || 'unknown'}`] : []),
180
+ ],
181
+ };
182
+ }
183
+ function sanitizeRoleName(role) {
184
+ // 文件名只允许 [A-Za-z0-9._-]
185
+ return role.replace(/[^A-Za-z0-9._-]/g, '_');
186
+ }
187
+ function buildMessageFile(frontmatter, body) {
188
+ const lines = ['---'];
189
+ lines.push(`v: ${frontmatter.v}`);
190
+ lines.push(`from: ${frontmatter.from}`);
191
+ lines.push(`fromPk: ${frontmatter.fromPk}`);
192
+ if (frontmatter.to)
193
+ lines.push(`to: ${frontmatter.to}`);
194
+ lines.push(`ts: ${frontmatter.ts}`);
195
+ if (frontmatter.p2pRef)
196
+ lines.push(`p2pRef: ${frontmatter.p2pRef}`);
197
+ if (frontmatter.git?.sha) {
198
+ lines.push('git:');
199
+ if (frontmatter.git.branch)
200
+ lines.push(` branch: ${frontmatter.git.branch}`);
201
+ if (frontmatter.git.sha)
202
+ lines.push(` sha: ${frontmatter.git.sha}`);
203
+ }
204
+ lines.push('---');
205
+ lines.push('');
206
+ return lines.join('\n') + body.trimEnd() + '\n';
207
+ }
208
+ export async function chatSend(opts) {
209
+ const { repoDir, body } = opts;
210
+ if (!body || !body.trim())
211
+ return { ok: false, reason: 'empty body', p2pNotifyEligible: false, filePath: '' };
212
+ if (Buffer.byteLength(body, 'utf8') > CHAT_BODY_MAX_BYTES) {
213
+ return { ok: false, reason: `body too large (${Buffer.byteLength(body, 'utf8')} > ${CHAT_BODY_MAX_BYTES})`, p2pNotifyEligible: false, filePath: '' };
214
+ }
215
+ const id = await resolveIdentity(opts.roleOverride);
216
+ const paths = chatPaths(repoDir);
217
+ await mkdirp(path.join(paths.root, id.role));
218
+ await mkdirp(paths.stateDir);
219
+ const ts = new Date().toISOString();
220
+ const shortHash = crypto.createHash('sha256').update(`${id.publicKey}|${ts}|${body}`).digest('hex').slice(0, 8);
221
+ const safeTs = ts.replace(/[:.]/g, '-');
222
+ const filename = `${safeTs}-${shortHash}.md`;
223
+ const relPath = path.join('.comm', sanitizeRoleName(id.role), filename);
224
+ const fm = {
225
+ v: CHAT_PROTOCOL_VERSION,
226
+ from: id.role,
227
+ fromPk: id.publicKey,
228
+ to: opts.to,
229
+ ts,
230
+ };
231
+ const content = buildMessageFile(fm, body);
232
+ const release = await acquireLock(paths.lockFile);
233
+ try {
234
+ // 1) 写盘
235
+ await fs.promises.writeFile(path.join(repoDir, relPath), content, 'utf8');
236
+ // 2) git add (限定 .comm/<self>/, 避免把别人未 push 的修改带进来)
237
+ await runGit(['add', '--', relPath], { cwd: repoDir });
238
+ // 3) commit
239
+ const commitMsg = `chat(${id.role}): ${body.split('\n')[0].slice(0, 60)}`.slice(0, COMMIT_MESSAGE_MAX);
240
+ const commit = await runGit(['commit', '-m', commitMsg], { cwd: repoDir });
241
+ if (commit.code !== 0) {
242
+ return { ok: false, reason: `git commit failed: ${commit.stderr.trim()}`, p2pNotifyEligible: false, filePath: relPath };
243
+ }
244
+ const shaLine = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim();
245
+ // 4) rebase + push (失败不回滚 commit, 让本地 commit 留下, 下次 send 重试 push)
246
+ await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
247
+ let pushed = false;
248
+ const push = await runGit(['push'], { cwd: repoDir });
249
+ if (push.code === 0)
250
+ pushed = true;
251
+ // 5) 自己的 cursor 推进
252
+ await writeJsonSafe(paths.cursorFile, { lastHead: shaLine, lastSelfTs: ts, role: id.role });
253
+ return {
254
+ ok: true,
255
+ sha: shaLine,
256
+ pushed,
257
+ p2pNotifyEligible: Buffer.byteLength(body, 'utf8') <= CHAT_P2P_NOTIFY_THRESHOLD,
258
+ filePath: relPath,
259
+ };
260
+ }
261
+ finally {
262
+ await release();
263
+ }
264
+ }
265
+ export async function chatPull(opts) {
266
+ const { repoDir } = opts;
267
+ const paths = chatPaths(repoDir);
268
+ await mkdirp(paths.stateDir);
269
+ const fetch = await runGit(['fetch', '--all', '--prune'], { cwd: repoDir });
270
+ if (fetch.code !== 0) {
271
+ return { ok: false, newCommits: 0, newMessages: [], reason: `git fetch failed: ${fetch.stderr.trim().split('\n').slice(-1)[0]}` };
272
+ }
273
+ const rebase = await runGit(['pull', '--rebase', '--autostash'], { cwd: repoDir });
274
+ // 收集新增消息: 对比 rev-list HEAD~ <self-cursor-sha>..HEAD 的 .comm/* 变更
275
+ const cursor = readJsonSafe(paths.cursorFile, {});
276
+ let range = 'HEAD';
277
+ if (cursor.lastHead) {
278
+ range = `${cursor.lastHead}..HEAD`;
279
+ }
280
+ const rev = await runGit(['rev-list', range, '--', '.comm'], { cwd: repoDir });
281
+ if (rev.code !== 0) {
282
+ return { ok: false, newCommits: 0, newMessages: [], reason: `rev-list failed: ${rev.stderr.trim()}` };
283
+ }
284
+ const shas = rev.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
285
+ const newMessages = [];
286
+ const seen = readJsonSafe(paths.seenFile, {});
287
+ const id = await resolveIdentity(opts.roleOverride);
288
+ for (const sha of shas) {
289
+ const show = await runGit(['show', '--name-only', '--pretty=format:', sha, '--', '.comm'], { cwd: repoDir });
290
+ const fileLines = show.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
291
+ for (const f of fileLines) {
292
+ if (!f.endsWith('.md'))
293
+ continue;
294
+ if (f.includes(`${path.sep}_state${path.sep}`) || f.includes(`${path.sep}_inbox${path.sep}`))
295
+ continue;
296
+ const abs = path.isAbsolute(f) ? f : path.join(repoDir, f);
297
+ const msg = parseMessageFile(abs, sha);
298
+ if (!msg)
299
+ continue;
300
+ // 不是自己发的: 收
301
+ if (msg.frontmatter.fromPk !== id.publicKey) {
302
+ const key = dedupeKey(msg);
303
+ if (!seen[key]) {
304
+ newMessages.push(msg);
305
+ seen[key] = Date.now();
306
+ }
307
+ // 关键: 每个对方消息都顺手 addOrUpdatePeer (幂等), 让 web 模式 5s 后自动 joinPeer 直连
308
+ // 即使消息之前见过, 也值得刷一次"lastConnectedAt"语义
309
+ try {
310
+ const { addOrUpdatePeer } = await import('../network/known-peers.js');
311
+ const name = msg.frontmatter.from || `peer-${msg.frontmatter.fromPk.slice(0, 8)}`;
312
+ addOrUpdatePeer(name, msg.frontmatter.fromPk, `auto from chat ${sha.slice(0, 8)}`);
313
+ }
314
+ catch {
315
+ // known-peers import / 写盘失败 — 不影响消息收集
316
+ }
317
+ }
318
+ }
319
+ }
320
+ await writeJsonSafe(paths.seenFile, seen);
321
+ // 推进 cursor
322
+ const head = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim();
323
+ await writeJsonSafe(paths.cursorFile, { ...cursor, lastHead: head });
324
+ return { ok: true, newCommits: shas.length, newMessages };
325
+ }
326
+ export async function chatStatus(opts) {
327
+ const { repoDir } = opts;
328
+ const id = await resolveIdentity(opts.roleOverride);
329
+ const remote = (await runGit(['remote', 'get-url', 'origin'], { cwd: repoDir })).stdout.trim() || undefined;
330
+ const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).stdout.trim() || undefined;
331
+ const head = (await runGit(['rev-parse', 'HEAD'], { cwd: repoDir })).stdout.trim() || undefined;
332
+ let ahead;
333
+ let behind;
334
+ if (remote && branch) {
335
+ const rev = await runGit(['rev-list', '--left-right', '--count', `${branch}...${remote}/${branch}`], { cwd: repoDir });
336
+ if (rev.code === 0) {
337
+ const [a, b] = rev.stdout.trim().split(/\s+/).map((n) => parseInt(n, 10));
338
+ ahead = a;
339
+ behind = b;
340
+ }
341
+ }
342
+ const files = listMessageFilesLocal(repoDir);
343
+ const byRole = {};
344
+ for (const f of files) {
345
+ const m = /[\\/].comm[\\/]([^\\/]+)[\\/]/.exec(f);
346
+ const role = m ? m[1] : '?';
347
+ byRole[role] = (byRole[role] || 0) + 1;
348
+ }
349
+ let mode = 'synced';
350
+ if (!remote)
351
+ mode = 'no-remote';
352
+ else if (ahead && ahead > 0)
353
+ mode = 'local-only';
354
+ return {
355
+ role: id.role,
356
+ publicKey: id.publicKey,
357
+ repoDir,
358
+ remote,
359
+ branch,
360
+ head: head ? head.slice(0, 12) : undefined,
361
+ ahead,
362
+ behind,
363
+ mode,
364
+ fileCount: files.length,
365
+ byRole,
366
+ };
367
+ }
368
+ function listMessageFilesLocal(repoDir) {
369
+ return listMessageFiles(repoDir);
370
+ }
@@ -0,0 +1,23 @@
1
+ // src/git-transport/chat-types.ts
2
+ // 共享类型与常量 — 不依赖任何运行时副作用,可在 import 链路最底层使用.
3
+ import * as path from 'path';
4
+ export const CHAT_PROTOCOL_VERSION = 1;
5
+ export const CHAT_BODY_MAX_BYTES = 256 * 1024; // 256 KiB 单条上限
6
+ export const COMMIT_MESSAGE_MAX = 8192; // git commit message 自身 8 KiB 上限
7
+ export const CHAT_P2P_NOTIFY_THRESHOLD = 4 * 1024; // > 4 KiB 只走 git, 不发 P2P notify
8
+ export const CHAT_DEFAULT_PULL_INTERVAL_MS = 15_000;
9
+ export const CHAT_PULL_BACKOFF_MAX_MS = 5 * 60_000;
10
+ export function chatPaths(repoDir) {
11
+ const root = path.join(repoDir, '.comm');
12
+ return {
13
+ root,
14
+ stateDir: path.join(root, '_state'),
15
+ inboxDir: path.join(root, '_inbox'),
16
+ cursorFile: path.join(root, '_state', 'cursor'),
17
+ p2pStatusFile: path.join(root, '_state', 'p2p-status.json'),
18
+ seenFile: path.join(root, '_state', 'seen.json'),
19
+ lockFile: path.join(root, '_state', 'git.lock'),
20
+ remoteFile: path.join(root, 'REMOTE'),
21
+ readmeFile: path.join(root, 'README.md'),
22
+ };
23
+ }