mindnet 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mindnet
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: mindnet — a reverse knowledge base. The index is you.
5
5
  Project-URL: Homepage, https://mindnet.tatoao.com
6
6
  License: MIT
@@ -0,0 +1,5 @@
1
+ """mindnet —— a reverse knowledge base. The index is you.
2
+
3
+ 单入口 CLI(发行面真相源 docs/方案-mindnet-cli合一-v1.md):四件散装收编成 `mindnet` 子命令。
4
+ """
5
+ __version__ = "0.3.0" # 0.3.0:bridge 多轮 thread(--resume 原生续聊·映射 ~/.mindnet/bot/threads/·丢了退 replay 重建)+ answer_chunk 逐字流式上行(老服务端/老帧向后兼容)
@@ -4,6 +4,8 @@
4
4
  两个角色(同一条 WS·/agent/channel):
5
5
  · 内脑通道(原 bridge):网页门 /ask 路由到你本地 agent 答(零计费)。
6
6
  · **空间 bot(新)**:同空间成员 @ 你的 bot → 云端推问题过来 → 本地 agent 答 → 带署名回对话流。
7
+ 多轮 thread(智能体升级 v1·契约 §4):ask 帧带 thread_id → `claude -p --resume` 原生续聊
8
+ (映射落 ~/.mindnet/bot/threads/)+ answer_chunk 逐字流式上行;老服务端/老帧全向后兼容。
7
9
 
8
10
  用法(mindnet 单入口·原 bridge/mindnet_bot.py 收编):
9
11
  mindnet login # device-code 网页授权,存 token(顶层收拢·mindnet bot login 同效)
@@ -35,6 +37,7 @@ import shutil
35
37
  import subprocess
36
38
  import sys
37
39
  import tempfile
40
+ import threading
38
41
  import time
39
42
  import urllib.parse
40
43
  import urllib.request
@@ -84,9 +87,14 @@ def select_exec_mode(capability: str) -> str:
84
87
 
85
88
 
86
89
  # ── 执行路径(claude 后端)──
87
- def _claude_cmd(prompt: str, system: str, extra: list, model: str | None = None) -> list:
90
+ def _claude_cmd(prompt: str, system: str, extra: list, model: str | None = None,
91
+ resume: str | None = None, output_format: str = "json") -> list:
88
92
  """拼 claude CLI 命令(纯函数·可单测)。model 显式给了才 --model 透传(真选执行模型·署名诚实底线)。
89
93
 
94
+ 多轮 thread 路(契约 §4):resume=session_id → `--resume` 原生续聊;
95
+ output_format="stream-json" → 逐字流式(成对带 --include-partial-messages --verbose,
96
+ print 模式下 stream-json 必须 --verbose 才吐行)。默认参数下形状与老单发路完全一致。
97
+
90
98
  --setting-sources project = 隐私洞修换轨(洞=TATOAO 07-09 真机坐实:claude -p 默认注入主人
91
99
  ~/.claude/CLAUDE.md 等个人配置,bot 会说给空间成员听)。原修用 --safe-mode,但它把工作区
92
100
  .claude/skills/ 也一并剥(勘察档 2026-07-10 实测死碰撞);换「只认项目级设置源」后四项全对:
@@ -94,17 +102,22 @@ def _claude_cmd(prompt: str, system: str, extra: list, model: str | None = None)
94
102
  不可用。三级(L0/L1/L2)共用本函数 = 全盖(L0 空 cwd 无项目源,效果等同全剥)。
95
103
  注意:邮箱/日期等环境注入 flag 层堵不掉(勘察档遗留①)——靠系统词硬约束兜(件6)。
96
104
  """
97
- cmd = ["claude", "-p", prompt, "--output-format", "json",
105
+ cmd = ["claude", "-p", prompt, "--output-format", output_format,
98
106
  "--system-prompt", system or DEFAULT_SYS,
99
107
  "--exclude-dynamic-system-prompt-sections",
100
108
  "--setting-sources", "project"]
109
+ if output_format == "stream-json":
110
+ cmd += ["--include-partial-messages", "--verbose"]
111
+ if resume:
112
+ cmd += ["--resume", resume]
101
113
  if model:
102
114
  cmd += ["--model", model]
103
115
  return cmd + list(extra)
104
116
 
105
117
 
106
- def _claude(prompt: str, system: str, cwd: str, extra: list, timeout: float,
107
- model: str | None = None) -> str:
118
+ def _claude_json(prompt: str, system: str, cwd: str, extra: list, timeout: float,
119
+ model: str | None = None) -> dict:
120
+ """单发跑 claude,返完整输出 dict(result 文本 + session_id 等)。失败抛 RuntimeError。"""
108
121
  cmd = _claude_cmd(prompt, system, extra, model)
109
122
  p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
110
123
  if p.returncode != 0:
@@ -112,7 +125,12 @@ def _claude(prompt: str, system: str, cwd: str, extra: list, timeout: float,
112
125
  out = json.loads(p.stdout)
113
126
  if out.get("is_error"):
114
127
  raise RuntimeError(str(out.get("result"))[:300])
115
- return (out.get("result") or "").strip()
128
+ return out
129
+
130
+
131
+ def _claude(prompt: str, system: str, cwd: str, extra: list, timeout: float,
132
+ model: str | None = None) -> str:
133
+ return str(_claude_json(prompt, system, cwd, extra, timeout, model).get("result") or "").strip()
116
134
 
117
135
 
118
136
  def _snapshot_workspace(workspace: str, cwd: str) -> None:
@@ -121,8 +139,16 @@ def _snapshot_workspace(workspace: str, cwd: str) -> None:
121
139
 
122
140
 
123
141
  def _answer_claude(prompt: str, system: str, mode: str, log, timeout: float = 180,
124
- model: str | None = None, workspace: str | None = None) -> str:
125
- """workspace=空间工作区根(外脑镜像+技能·件4)。L0 不吃 workspace(v1.1 钉·维持测试档)。"""
142
+ model: str | None = None, workspace: str | None = None,
143
+ thread_id: str | None = None, replay=None, on_chunk=None) -> str:
144
+ """workspace=空间工作区根(外脑镜像+技能·件4)。L0 不吃 workspace(v1.1 钉·维持测试档)。
145
+
146
+ thread_id 有值=多轮 thread 路(契约 §4:--resume 续聊 + answer_chunk 逐字上行);
147
+ 无值=老单发路,行为一字不变(老帧向后兼容)。
148
+ """
149
+ if thread_id:
150
+ return _answer_claude_thread(prompt, system, mode, log, timeout, model, workspace,
151
+ thread_id, replay, on_chunk)
126
152
  if mode == "l0":
127
153
  log(" · L0 纯问答(空 cwd·禁全部工具)")
128
154
  with tempfile.TemporaryDirectory() as cwd:
@@ -153,9 +179,16 @@ def _codex_cmd(full: str, sandbox: str, cwd: str, model: str | None = None) -> l
153
179
 
154
180
 
155
181
  def _answer_codex(prompt: str, system: str, mode: str, log, timeout: float = 180,
156
- model: str | None = None, workspace: str | None = None) -> str:
182
+ model: str | None = None, workspace: str | None = None,
183
+ thread_id: str | None = None, replay=None, on_chunk=None) -> str:
157
184
  """codex exec 非交互作答。L0=沙箱只读(不吃 workspace·v1.1);L1=workspace-write 快照临时 cwd;
158
- L2=danger 全权·空间工作区真目录(警示)。工作区说明走 cwd 的 AGENTS.md(codex 原生注入·勘实)。"""
185
+ L2=danger 全权·空间工作区真目录(警示)。工作区说明走 cwd 的 AGENTS.md(codex 原生注入·勘实)。
186
+
187
+ 多轮对称(契约 §4):thread_id 有值 → 每轮 replay 重放重建(codex exec 无稳定 session 续聊面,
188
+ 诚实不硬凑映射);整段一次回=服务端当单 chunk(on_chunk 不用),不炸不装流。
189
+ """
190
+ if thread_id:
191
+ prompt = format_replay(replay, prompt)
159
192
  full = (system or DEFAULT_SYS) + "\n\n【问题】\n" + prompt
160
193
  if mode == "l0":
161
194
  log(" · L0 纯问答(codex·只读沙箱)")
@@ -180,6 +213,183 @@ def make_answerer(backend: str):
180
213
  return _answer_codex if backend == "codex" else _answer_claude
181
214
 
182
215
 
216
+ # ── 多轮 thread(智能体升级 v1·契约 §4)──
217
+ # ask 帧扩 thread_id + replay(服务端已裁窗);bridge 维护 thread_id→本地 session 映射
218
+ # (状态文件 ~/.mindnet/bot/threads/sessions.json + per-thread 稳定 cwd——claude session
219
+ # 按项目目录归档,cwd 不稳 --resume 找不到)。映射在=--resume 原生续聊(订阅侧免重放,
220
+ # 零计费路再白拿一次);映射丢/resume 报错=弃映射退 replay 重放重建,功能不减。
221
+
222
+ def _bot_threads_dir() -> Path:
223
+ return Path(os.environ.get("MINDNET_BOT_THREADS_DIR",
224
+ str(Path.home() / ".mindnet" / "bot" / "threads")))
225
+
226
+
227
+ def _safe_thread_key(thread_id: str) -> str:
228
+ """thread_id 落盘前消毒(云侧数据不全信·与 _ws_safe_rel 同纪律):只留字母数字与 -_。"""
229
+ key = "".join(c for c in (thread_id or "") if c.isalnum() or c in "-_")
230
+ if not key:
231
+ raise ValueError(f"非法 thread_id:{thread_id!r}")
232
+ return key
233
+
234
+
235
+ def load_thread_sessions() -> dict:
236
+ p = _bot_threads_dir() / "sessions.json"
237
+ try:
238
+ return json.loads(p.read_text()) if p.exists() else {}
239
+ except Exception:
240
+ return {} # 状态文件坏=当空表:丢的只是 --resume 优化,replay 重建兜底
241
+
242
+
243
+ def save_thread_session(thread_id: str, entry: dict) -> None:
244
+ d = _bot_threads_dir()
245
+ d.mkdir(parents=True, exist_ok=True)
246
+ s = load_thread_sessions()
247
+ s[thread_id] = entry
248
+ (d / "sessions.json").write_text(json.dumps(s, ensure_ascii=False))
249
+
250
+
251
+ def drop_thread_session(thread_id: str) -> None:
252
+ s = load_thread_sessions()
253
+ if thread_id in s:
254
+ del s[thread_id]
255
+ (_bot_threads_dir() / "sessions.json").write_text(json.dumps(s, ensure_ascii=False))
256
+
257
+
258
+ def thread_cwd(thread_id: str) -> Path:
259
+ p = _bot_threads_dir() / _safe_thread_key(thread_id)
260
+ p.mkdir(parents=True, exist_ok=True)
261
+ return p
262
+
263
+
264
+ def format_replay(replay, prompt: str) -> str:
265
+ """映射丢失的重建路(纯函数):把服务端重放的中立历史嵌进单发 prompt。空 replay=原 prompt。"""
266
+ if not replay:
267
+ return prompt
268
+ lines = [("用户" if m.get("role") == "user" else "助手") + ":" + str(m.get("content") or "")
269
+ for m in replay]
270
+ return ("【此前对话(服务端重放·你就是其中的助手,接着聊)】\n" + "\n\n".join(lines)
271
+ + "\n\n【当前问题】\n" + prompt)
272
+
273
+
274
+ def parse_stream_line(line: str):
275
+ """claude `--output-format stream-json` 单行 → ("chunk", 增量文本) / ("result", {text,
276
+ session_id, is_error}) / ("other", None)。纯函数·可单测;坏行一律 other 不炸。"""
277
+ try:
278
+ d = json.loads(line)
279
+ except Exception:
280
+ return ("other", None)
281
+ t = d.get("type")
282
+ if t == "stream_event":
283
+ ev = d.get("event") or {}
284
+ if ev.get("type") == "content_block_delta":
285
+ delta = ev.get("delta") or {}
286
+ if delta.get("type") == "text_delta" and delta.get("text"):
287
+ return ("chunk", delta["text"])
288
+ return ("other", None)
289
+ if t == "result":
290
+ return ("result", {"text": str(d.get("result") or "").strip(),
291
+ "session_id": d.get("session_id"),
292
+ "is_error": bool(d.get("is_error"))})
293
+ return ("other", None)
294
+
295
+
296
+ def _claude_stream(prompt: str, system: str, cwd: str, extra: list, timeout: float,
297
+ model: str | None = None, resume: str | None = None,
298
+ on_chunk=None) -> tuple:
299
+ """流式跑 claude(thread 路):stream-json 逐字回调 on_chunk,返 (整段文本, session_id)。
300
+
301
+ · resume 报错**上抛**(调用方弃映射退 replay 重试)——绝不静默退单发,那会丢上下文;
302
+ · 非 resume 且 CLI 接不住 stream-json(旧版无 flag)→ 退老单发 json 整段(诚实单 chunk,
303
+ session_id 也从 json 输出里拿,多轮照样建映射)。
304
+ """
305
+ cmd = _claude_cmd(prompt, system, extra, model, resume=resume, output_format="stream-json")
306
+ chunks: list = []
307
+ result = None
308
+ p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
309
+ killer = threading.Timer(timeout, p.kill) # 行读没有 run(timeout=) 可用,watchdog 兜死等
310
+ killer.start()
311
+ try:
312
+ for line in p.stdout:
313
+ kind, payload = parse_stream_line(line)
314
+ if kind == "chunk":
315
+ chunks.append(payload)
316
+ if on_chunk:
317
+ try:
318
+ on_chunk(payload)
319
+ except Exception:
320
+ pass # 上行帧失败不打断作答:终帧 answer 仍是完整文本
321
+ elif kind == "result":
322
+ result = payload
323
+ rc = p.wait()
324
+ finally:
325
+ killer.cancel()
326
+ try:
327
+ p.kill()
328
+ except Exception:
329
+ pass
330
+ if result is not None:
331
+ if result["is_error"]:
332
+ raise RuntimeError((result["text"] or "claude 报错")[:300])
333
+ return result["text"] or "".join(chunks), result.get("session_id")
334
+ stderr = (p.stderr.read() or "")[:300] if p.stderr else ""
335
+ if resume:
336
+ raise RuntimeError(stderr or f"claude --resume 失败(退出码 {rc})")
337
+ # 旧 CLI 接不住 stream-json → 单发 json 兜底(整段=answer 终帧一次回,服务端当单 chunk)
338
+ out = _claude_json(prompt, system, cwd, extra, timeout, model)
339
+ text = str(out.get("result") or "").strip()
340
+ if on_chunk and text:
341
+ try:
342
+ on_chunk(text)
343
+ except Exception:
344
+ pass
345
+ return text, out.get("session_id")
346
+
347
+
348
+ def _chunk_sender(ws, loop, request_id: str):
349
+ """answer_chunk 上行帧回调(契约 §4)。answerer 跑在工作线程(asyncio.to_thread),
350
+ 经 run_coroutine_threadsafe 投回事件循环发帧;发不出不致命(终帧 answer=完整文本兜底,
351
+ 老服务端收到未知帧忽略不炸=向后兼容)。"""
352
+ def cb(text: str) -> None:
353
+ if not text:
354
+ return
355
+ frame = json.dumps({"type": "answer_chunk", "request_id": request_id, "text": text})
356
+ try:
357
+ asyncio.run_coroutine_threadsafe(ws.send(frame), loop)
358
+ except Exception:
359
+ pass
360
+ return cb
361
+
362
+
363
+ def _answer_claude_thread(prompt: str, system: str, mode: str, log, timeout: float,
364
+ model: str | None, workspace: str | None,
365
+ thread_id: str, replay, on_chunk) -> str:
366
+ """多轮 thread 的 claude 路:稳定 per-thread cwd + 映射 --resume;丢了退 replay 重建。"""
367
+ extra = (["--disallowed-tools", *_ALL_TOOLS] if mode == "l0"
368
+ else ["--disallowed-tools", *_L1_DISALLOW] if mode == "l1_restricted" else [])
369
+ cwd = Path(workspace) if (mode == "l2" and workspace) else thread_cwd(thread_id)
370
+ if workspace and mode == "l1_restricted":
371
+ _snapshot_workspace(workspace, str(cwd))
372
+ sess = load_thread_sessions().get(thread_id) or {}
373
+ sid = sess.get("session_id") if sess.get("backend", "claude") == "claude" else None
374
+ if sid:
375
+ try:
376
+ log(f" · thread {thread_id[:16]} --resume 原生续聊")
377
+ text, new_sid = _claude_stream(prompt, system, str(sess.get("cwd") or cwd), extra,
378
+ timeout, model, resume=sid, on_chunk=on_chunk)
379
+ save_thread_session(thread_id, {"session_id": new_sid or sid,
380
+ "backend": "claude", "cwd": str(cwd)})
381
+ return text
382
+ except Exception as e:
383
+ log(f" ⚠ resume 失败({str(e)[:80]}),弃映射退重放重建")
384
+ drop_thread_session(thread_id)
385
+ text, new_sid = _claude_stream(format_replay(replay, prompt), system, str(cwd), extra,
386
+ timeout, model, on_chunk=on_chunk)
387
+ if new_sid:
388
+ save_thread_session(thread_id, {"session_id": new_sid, "backend": "claude",
389
+ "cwd": str(cwd)})
390
+ return text
391
+
392
+
183
393
  # ── bot 工作区(件3 云→本地拉·manifest 下行)──
184
394
  # 工作区布局(方向档 §一):~/.mindnet/bot/spaces/<space_id>/{外脑/, .claude/skills/, .pull-manifest.json}
185
395
  BRAIN_DIR = "外脑"
@@ -429,8 +639,14 @@ async def run(exposed_caps: dict, backend: str, model: str, once: bool = False,
429
639
  workspace = None
430
640
  if space_id and mode != "l0": # L0 不吃工作区(v1.1 钉·测试档)
431
641
  workspace = await asyncio.to_thread(_workspace_for, space_id, token, log)
642
+ # 多轮 thread(契约 §4·仅有值才走):--resume 续聊 + answer_chunk 逐字上行
643
+ thread_id = msg.get("thread_id")
644
+ on_chunk = (_chunk_sender(ws, asyncio.get_running_loop(), rid)
645
+ if thread_id else None)
432
646
  text = await asyncio.to_thread(answerer, msg.get("prompt", ""), system,
433
- mode, log, model=exec_model, workspace=workspace)
647
+ mode, log, model=exec_model, workspace=workspace,
648
+ thread_id=thread_id, replay=msg.get("replay"),
649
+ on_chunk=on_chunk)
434
650
  await ws.send(json.dumps({"type": "answer", "request_id": rid, "ok": True, "text": text}))
435
651
  log(f" ✓ 答了({len(text)} 字)")
436
652
  except Exception as e:
@@ -1,5 +0,0 @@
1
- """mindnet —— a reverse knowledge base. The index is you.
2
-
3
- 单入口 CLI(发行面真相源 docs/方案-mindnet-cli合一-v1.md):四件散装收编成 `mindnet` 子命令。
4
- """
5
- __version__ = "0.2.0" # 0.2.0:space-pull 下行同步(语义四条+快照统一 .mindnet-sync.json+status 双向)+ 图片进白名单
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes