ai_co_discuss 0.3.20 → 0.3.23

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 (44) hide show
  1. package/.agents/skills/discuss-host/SKILL.md +1 -1
  2. package/.agents/skills/discuss-host/references/discussion.md +1 -1
  3. package/.agents/skills/discuss-host/references/launch.md +1 -1
  4. package/.agents/skills/discuss-host/references/manual.md +3 -19
  5. package/.agents/skills/discuss-participant/SKILL.md +2 -2
  6. package/.agents/skills/discuss-participant/references/manual.md +1 -1
  7. package/.agents/skills/discuss-participant/references/writing.md +4 -9
  8. package/.agents/skills/discuss-report/SKILL.md +1 -1
  9. package/.agents/skills/discuss-report/assets/Discussion_Report_Template.md +2 -2
  10. package/.github/SECURITY.md +1 -1
  11. package/README.md +1 -3
  12. package/README.zh-CN.md +1 -3
  13. package/backend/free_room.py +26 -83
  14. package/backend/migrations/drop_legacy_discussion.sql +60 -0
  15. package/backend/remote_launch.py +10 -4
  16. package/backend/room_import.py +4 -3
  17. package/backend/schema.sql +231 -3
  18. package/backend/server.py +156 -149
  19. package/backend/session_room.py +7 -12
  20. package/docs/architecture.md +2 -2
  21. package/docs/deployment.md +1 -1
  22. package/docs/en/architecture.md +1 -2
  23. package/docs/en/deployment.md +1 -1
  24. package/docs/en/runner.md +10 -0
  25. package/docs/en/workflow.md +4 -5
  26. package/docs/runner.md +18 -2
  27. package/docs/workflow.md +3 -4
  28. package/package.json +1 -1
  29. package/runner/adapters/api.mjs +29 -10
  30. package/runner/adapters/api_files.mjs +369 -0
  31. package/runner/adapters/profiles.mjs +6 -3
  32. package/runner/discuss.py +7 -14
  33. package/runner/discussion_api.mjs +5 -3
  34. package/runner/discussion_launch.mjs +5 -5
  35. package/runner/discussion_local.mjs +10 -4
  36. package/runner/discussion_runner.mjs +4 -5
  37. package/runner/discussion_setup.mjs +11 -4
  38. package/runner/free_runner.mjs +5 -3
  39. package/runner/scheduler.mjs +2 -2
  40. package/web/app.js +86 -102
  41. package/web/backup.js +1 -1
  42. package/web/index.html +6 -6
  43. package/web/modern.css +1 -1
  44. package/web/modern.js +3186 -4306
@@ -18,7 +18,7 @@ description: "AI CoDiscuss 的主持技能,仅主持人调用,且须同时
18
18
  | 自由协作请求(任务含 `mode: "free"`、`role: "host"`,且没有 `discussion_request`) | [自由协作主持](references/free.md) |
19
19
  | 协作房间中的讨论主持任务(`mode` 仍为 `free`,`discussion_request.purpose` 为 `discuss_host`) | [讨论主持](references/discussion.md) |
20
20
  | 用户要开始新讨论,当前会话还没有自动任务 | [新建讨论与从 AI 对话发起](references/launch.md) |
21
- | 本地手动成员注册说明,或网页来源成员在自由协作 / 讨论中的代发 | [手动接入与网页成员代发](references/manual.md) |
21
+ | 本地手动成员注册说明,或安排手动成员发言 | [手动成员](references/manual.md) |
22
22
 
23
23
  ## 硬约束
24
24
 
@@ -45,7 +45,7 @@
45
45
  - 安排须带本次读取的 `revision`;整轮计划还须包含 `waiting` 中的每位专家。`unregistered` 中尚未注册的手动专家不能排入,它注册后会自动加入本轮队列。名单变化后 `revision` 失效需重取,缺人或未带 `revision` 都会被拒绝;本轮不需要某人时先让其暂离。
46
46
  - 计划只覆盖当前一轮,跨轮追问要看本轮回答后再定;只有各轮问题不依赖回答的固定流程(如依次报数)才可排满多轮。后一位的问题取决于前一位回答时也不必等前一位答完:把问题写成“先读取最新回答,再按……回应”,由专家自己读取。
47
47
  - 网站在专家间自动接力,末位答完才交回主持。用户留言或打断时服务端会自动把控制权交回主持。
48
- - `members` 中 `adapter` 为 `api` 的专家读不到本地文件,只能从对话了解项目。议题涉及本地文件时,开场主持说明应交代项目情况(相关文件、结构、要点,必要时贴关键片段);`initial` 中它仅按任务的 `read_until` 阅读边界作答;进入交锋后可把它排在能读文件的专家之后,问题里请它阅读允许范围内的回答;它索要的信息由能读文件的专家补充。
48
+ - `members` 中 `adapter` 为 `api` 的专家没有命令行;房间有项目目录时它能用文件工具读取、搜索项目文件,用户给了可写权限时还能新建和编辑文件,没有项目目录时读不到本地文件。网站不记录它的项目权限,议题涉及本地文件时,开场主持说明写明相关文件路径和要点,让它自己读取;它说明读不到或索要信息时,由能读文件的专家补充,必要时贴关键片段。需要运行测试、构建或 git 的工作交给 CLI 专家。`initial` 中它同样按任务的 `read_until` 阅读房间消息。
49
49
 
50
50
  ### 名单变化
51
51
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  当前实际会话承担主持,不另建调度聊天。沿用用户指定的房间和推进方式;新讨论尚未选择时,只确认“全自动讨论 / 手动复制冷启动”。入口与推进方式、CLI / App 客户端分别判断。
8
8
 
9
- 未指定房间时,保存目标、背景、参与配置和上限,核验后再接入专家。全自动使用下文启动入口,手动模式沿用 CLI 建房与接入流程。全自动要求主持和专家均具备本地 CLI 读写能力;配置完成后依据用户的启动指令运行,普通建房本身不代表已启动。在线只读专家使用手动接入分工,不静默转换模式。
9
+ 未指定房间时,保存目标、背景、参与配置和上限,核验后再接入专家。全自动使用下文启动入口,手动模式沿用 CLI 建房与接入流程。全自动要求主持和专家均能由本机服务唤醒(本地 CLI / App 或用户配置的模型 API);配置完成后依据用户的启动指令运行,普通建房本身不代表已启动。
10
10
 
11
11
  新建讨论时,将用户请求完整保存到房间独立的“用户原文”字段(CLI 使用 `--user-request-file`),不改写或概括;“背景”只保存补充材料。原文超过该字段 4000 字上限时,将完整原文按参与技能保存为开场主持消息,“用户原文”保留原文消息链接,不截断原文。
12
12
 
@@ -1,25 +1,9 @@
1
- # 手动成员与网页成员代发
1
+ # 手动成员
2
2
 
3
- 本页说明本地手动成员注册,以及网页来源成员在自由协作或讨论中的代发。网页成员使用 `manual_source=web`,不需要本地注册或成员凭据;不能担任主持人、直接写入或由接入程序唤醒。它们可以进入讨论安排,主持人按安排向原聊天转交问题,再由主持人或用户代发原文。主持与等待仍由房间请求、讨论会话及上限管理,不创建原生定时任务。
3
+ 本页说明本地手动成员在自由协作或讨论中的注册与安排。主持与等待仍由房间请求、讨论会话及上限管理,不创建原生定时任务。
4
4
 
5
5
  ## 本地手动成员
6
6
 
7
7
  本地手动成员须先用自己的成员凭据执行 `member register ROOM --as 本人`。CLI 自动附带当前软件会话 ID;不要代替成员注册,也不要填写或借用其他会话 ID。尚未注册的本地成员显示为“待注册”,不能排入讨论或被本机服务托管。注册后是否能投递到原会话、窗口显示是否实时,按参与技能的 [手动接入说明](../../discuss-participant/references/manual.md) 告知用户。
8
8
 
9
- 安排讨论前读取 `turn roster ROOM`。`unregistered` 表示尚未注册的本地手动成员;网页来源成员不需注册,可按其 `waiting` 状态正常安排。本地成员完成注册后,重新读取 roster 与 revision,再创建安排。
10
-
11
- ## 网页来源成员
12
-
13
- 网页 AI 只能在原聊天形成回答,不能访问本地 CLI 或 localhost。自由协作与讨论安排都可由网页成员回答;主持人先确认浏览器由主持人操作还是由用户手动复制,之后沿用已确认的分工,不逐轮重复询问。讨论按当前安排发送对应问题;没有获准操作浏览器时,请用户手动转交。
14
-
15
- 仅在用户已授权主持人操作浏览器时,复用已登录的 Chrome 和对应聊天,采用后台操作,不抢占用户前台。先核对正确聊天、当前请求和已有记录,再发送完整问题。中断或回执不明时按实际页面证据恢复:
16
-
17
- | 页面证据 | 处理 |
18
- | --- | --- |
19
- | 已出现本次问题或对应回答 | 视为已送达,记录真实结果,不重复发送。 |
20
- | 已检查正确聊天且确认问题未出现 | 重新核对房间状态后,发送同一问题并确认页面结果。 |
21
- | 页面、聊天归属或送达情况无法确认 | 保留结果不明并报告障碍,停止重发。 |
22
-
23
- 用户手动转交时,提供完整、可复制的问题,等待用户贴回网页专家的完整原文;不得把问题展示文本当作已送达。收到回答后核对它对应的仍有效请求,再使用主持人凭据以网页成员为作者提交,并关联请求,例如 `message post ROOM --as 主持人 --author 网页成员 --kind chat --request 请求编号 --body-file answer.md --submission-id 原编号`。保留原文、作者、引用与来源;主持人整理的摘要须标明由主持人撰写,不能把改写冒充专家原文。网页专家无法访问本地文件时,明确说明并按需提供获准的必要材料。
24
-
25
- 浏览器送达与网站写入分别核对。网站提交编号只能去重网站写入,不能证明浏览器消息已送达;原文代发失败或结果不明时,按参与技能先查询原 submission_id,再决定是否重试,并保持原编号和正文不变。
9
+ 安排讨论前读取 `turn roster ROOM`。`unregistered` 表示尚未注册的本地手动成员。本地成员完成注册后,重新读取 roster 与 revision,再创建安排。
@@ -18,7 +18,7 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
18
18
  | 自由协作请求(任务含 `mode: "free"`,且没有 `discussion_request`) | [自由协作](references/free.md) |
19
19
  | 协作房间中的讨论请求(任务含 `mode: "free"` 和 `discussion_request`,或手动成员看到自己的讨论请求) | [讨论请求](references/discussion.md) |
20
20
  | 手动成员注册、原会话或手动回答 | [手动接入](references/manual.md) |
21
- | 首次某类写入、代发、超长正文、附件或出错 | [写入与恢复](references/writing.md) |
21
+ | 首次某类写入、超长正文、附件或出错 | [写入与恢复](references/writing.md) |
22
22
 
23
23
  ## 硬约束
24
24
 
@@ -60,4 +60,4 @@ description: "AI CoDiscuss 的通用参与技能,所有参与者(包括主
60
60
 
61
61
  正文用 UTF-8 `--body-file`,摘要含引号或较长时用 `--summary-file`。需要附件时先 `attachment upload ROOM --file FILE --submission-id 上传编号`,再用 `message post --attachment ID` 关联(最多 10 个,单个 20 MiB,须在执行资格结束前上传)。
62
62
 
63
- 成功须核对服务端返回的持久记录、作者和关联;返回已是持久记录时不再读一遍,缺少所需证据才按编号补读。答案落库后即可结束,不用旧资格继续写入。代发保留真实作者、完整原文及引用关系,摘要归实际撰写者,不把自己的改写冒充对方回答。执行结束不等于整个房间的工作结束。
63
+ 成功须核对服务端返回的持久记录、作者和关联;返回已是持久记录时不再读一遍,缺少所需证据才按编号补读。答案落库后即可结束,不用旧资格继续写入。只以本人身份提交,不替他人发言。执行结束不等于整个房间的工作结束。
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## 接入文件与注册
4
4
 
5
- 没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名,在线成员没有自行写入资格。
5
+ 没有任务文件本身不代表手动授权:若房间正在自动运行,等待本次交付或核对缺项,不从旧文件接管。所有房间的手动本地成员都使用自己独立的私有接入文件,写入带 `--access-file`;文件路径可私下交付,token 不进入提示或房间。房主控制文件不能代替成员文件署名。
6
6
 
7
7
  首次读取房间后立即执行一次 `member register ROOM --as 本人 --access-file FILE`。未注册前网站显示“待注册”,不算到场,也不会被安排发言或被本机服务接管。会话 ID 由 CLI 从宿主会话自动附带,不自行填写;凭证重新签发后须重新注册。
8
8
 
@@ -1,18 +1,16 @@
1
1
  # 写入与恢复
2
2
 
3
- 首次使用某类写入、需要在线代发、处理结果不明或超长正文时查阅相关部分。正常讨论按主技能完成必要读写,不逐轮重读本页或整套 CLI 帮助。
3
+ 首次使用某类写入、处理结果不明或超长正文时查阅相关部分。正常讨论按主技能完成必要读写,不逐轮重读本页或整套 CLI 帮助。
4
4
 
5
- ## 命令发现与在线代操作
5
+ ## 命令发现
6
6
 
7
7
  沿用主技能确定的命令入口,不在技能目录另写 HTTP 客户端。命令不确定时用 `codiscuss schema message post` 或对应 `--help`;任务指定脚本入口或未安装统一命令时,在该入口后加 `schema message post` / `--help`。角色限制以目标站点 `room protocol` 为准。
8
8
 
9
- 网页来源成员(`manual_source=web`)在原聊天形成回答,不需本地注册或成员凭据;它可回答自由协作请求和讨论安排,但不能直接写入或担任主持人。主持人或用户核实原回答后,用有权的房间凭据以网页成员为作者代发;讨论回答还须关联仍有效的请求。不能声称网页专家调用了本地 CLI,也不能把主持人的改写冒充原文。
10
-
11
9
  ## 可靠写入
12
10
 
13
11
  每次业务写入都带对应的 `--access-file`:手动成员使用本人的持久凭据,自动讨论使用本次执行文件。房主文件仅用于用户身份和房主管理。CLI 建房须显式指定私有房主文件,先保存建房编号;回执中的 `member_access_files` 给出各本地成员文件路径。讨论名单或主持职责变更仅房主可做,新增或改变能力的成员须提供独立的新凭据;保持未变成员原凭据,不能由成员自行扩权。
14
12
 
15
- 本地提交用 `message post --summary` 或 `--summary-file` 保存摘要,`--body` / `--body-file` 保存完整正文;摘要上限 1000 个 Unicode 字符。自己的摘要默认归自己。代发原作者提供的摘要时用 `--summary-by 原作者`;由主持人或用户另行整理时注明实际撰写者,默认是操作者,不能冒充原作者。摘要与正文一起使用同一提交编号,回读核对两者和归属。在线专家在原聊天分开摘要与完整正文,交由主持人留档;缺少摘要的旧记录不自动生成摘要。
13
+ 本地提交用 `message post --summary` 或 `--summary-file` 保存摘要,`--body` / `--body-file` 保存完整正文;摘要上限 1000 个 Unicode 字符。摘要归提交者本人。摘要与正文一起使用同一提交编号,回读核对两者和归属;缺少摘要的旧记录不自动生成摘要。
16
14
 
17
15
  一次写入先确定目标、身份、正文与关联关系,再选择对应命令:
18
16
 
@@ -20,13 +18,12 @@
20
18
  | --- | --- |
21
19
  | 自由协作回答或讨论专家回答 | `message post --kind chat`;自动执行使用 `--execution 执行编号`,手动成员使用 `--request 请求编号` |
22
20
  | 主持说明 | `message post --kind moderation`,仅主持人在允许的房间状态下使用;该消息本身不结束讨论 |
23
- | 网页来源成员代发(自由协作或讨论) | 使用 `message post --kind chat --author 网页成员`,讨论回答同时用 `--request 请求编号` 关联安排 |
24
21
  | 创建 / 维护话题 | `room create` / `room update`;成员请通过成员管理调整 |
25
22
  | 安排发言 / 取消 | `turn create` / `turn cancel`;同房间只能有一个未完成安排 |
26
23
  | 创建或继续安排 | `turn roster` 读取最新名单与 `revision`,随后 `turn create` 或 `turn continue` 携带该版本 |
27
24
  | 保存摘要 | `state set`,`--until` 只覆盖实际已读消息 |
28
25
 
29
- 主持类写入需要本次任务授予主持职责。网页成员代发须保留真实作者、完整原文、关联请求、原答复的链接/引用对应关系及同房间关联,不把自己的改写冒充他人原文。主持人补充的引用附录需明确标注来源与归属,无法取得的出处说明缺失;不把自己的核验结论混进专家原文。
26
+ 主持类写入需要本次任务授予主持职责。主持人补充的引用附录需明确标注来源与归属,无法取得的出处说明缺失;不把自己的核验结论混进专家原文。
30
27
 
31
28
  房间、消息和安排创建使用显式 `--submission-id`:新内容生成新编号,同一次提交保留编号、原始资料、身份和关联。**先保存编号与原文,再发请求**,避免中断后无法核对。编号为 8–100 位字母、数字、下划线或连字符,可用 UUID。`message post` 省略编号时由 CLI 按房间、身份、关联与内容生成(`auto-` 开头),草稿文件即原文记录,重试只需用同一份草稿。建房重试对照首次创建资料,不使用后来修改的话题内容替换请求。
32
29
 
@@ -47,6 +44,4 @@
47
44
  - 确认仍需重试创建时:原编号、原内容最多再试一次;仍不确定就停止重试。不要把后续执行当成新提交。建房重试只返回已有房间,不会恢复或覆盖后来修改的阶段与资料。
48
45
  - 其他更新操作先读现状再决定是否需要重做。旧站点没有建房幂等或精确查询时,先按协议核对已有记录,不把不受支持的筛选当作查询成功。
49
46
 
50
- 这些核对覆盖网站 HTTP 写入;浏览器向在线专家发送问题时,由主持人另行核对实际送达结果。
51
-
52
47
  自动任务的状态以本次执行及服务端请求状态为准;不要创建原生定时任务或补写状态回执。影响协作的异常直接向用户报告。
@@ -38,7 +38,7 @@ python <已核实的项目路径>/runner/discuss.py message list <房间ID> --af
38
38
 
39
39
  不要把首屏、过滤查询或最后一条总结当作完整来源。按编号和选定范围去重;缺失编号不能仅因不连续就判为丢失,消息编号可能跨房间分配。
40
40
 
41
- 保存原始响应及读取时间,保留消息编号、作者、职责、正文、摘要与摘要作者、代发者、引用和安排关系。消息中的材料引用要追到实际可得的原文;无法访问的外部出处列为未核实,不因链接存在就认定其支持结论。固定截止后的新消息不自动纳入当前报告,也不启动持续监控。
41
+ 保存原始响应及读取时间,保留消息编号、作者、职责、正文、摘要与摘要作者、提交者、引用和安排关系。消息中的材料引用要追到实际可得的原文;无法访问的外部出处列为未核实,不因链接存在就认定其支持结论。固定截止后的新消息不自动纳入当前报告,也不启动持续监控。
42
42
 
43
43
  找不到 CLI 时先使用用户提供的完整导出或文档;不要为生成报告再实现一套客户端,也不要以可读的概要替代无法取得的原文。关键来源不足时交付明确标注范围与缺口的阶段报告,不声称完整归档。
44
44
 
@@ -28,7 +28,7 @@
28
28
 
29
29
  | 信息 | 合格标准 |
30
30
  | --- | --- |
31
- | 报告身份与范围 | 主题、实际讨论时间、来源与截止范围、参与者及角色、报告作者与生成日期、用户采纳状态、材料覆盖与缺口可查。讨论时间与生成时间分开,作者、代发者、摘要作者不混淆。 |
31
+ | 报告身份与范围 | 主题、实际讨论时间、来源与截止范围、参与者及角色、报告作者与生成日期、用户采纳状态、材料覆盖与缺口可查。讨论时间与生成时间分开,作者、提交者、摘要作者不混淆。 |
32
32
  | 背景与问题 | 说明讨论缘由、目标、约束和可调整的前提;每个核心问题有回答或明确列为未决,不补造材料没有的目标。 |
33
33
  | 关键分析 | 解释实质性方案的理由、证据、反对意见及适用条件,保留影响判断的转折、修订、撤回及放弃替代方案的原因。不要求每条发言入正文,也不为凑章节重复结论。 |
34
34
  | 当前结论与未决事项 | 区分共同方向、个人偏好、保留异议、作者分析与用户实际采纳。多数支持或未发现异议不等于全员认可;未确认不等于否决。撤回的主张不再作为当前依据,已解决的争议不继续列为未决。后续建议说明验证目的与判断依据,负责人、日期与承诺仅在来源明确时记录。 |
@@ -62,4 +62,4 @@
62
62
  ## 来源与归档说明
63
63
 
64
64
  {{具体来源与本地副本的对应索引、范围及必要的版本说明}}
65
- ```
65
+ ```
@@ -4,7 +4,7 @@
4
4
 
5
5
  项目没有用户登录、读取权限隔离或租户隔离,默认站点只由信任的使用者访问。所有新房间的用户写入、运行控制和成员管理都要求房主控制凭据;`--as`、作者、主持人只是声明,不能代替认证。本地成员只能使用自己的凭据和获授职责,房主凭据也不能冒充本地 AI 发言。服务端仅保存凭据哈希,写入与资格校验在同一事务完成。
6
6
 
7
- 手动本地成员使用独立的持久成员凭证;在线成员不能凭自己的凭证写入,只能由房主或讨论主持人代发真实原文。自动讨论的 AI 写入须使用本次执行凭据,校验身份、职责、安排、运行代次和租约;自由房间的自动回答须同时携带成员凭证和有效执行编号。失效执行不能改作普通发言,持久成员凭证不能绕过自动讨论的执行检查。自由成员离开后凭证立即失效,请回时换发新凭证;讨论名单删去的成员同样停用。
7
+ 手动本地成员使用独立的持久成员凭证;成员只能以本人身份写入,房主凭据只能以用户身份发言。自动讨论的 AI 写入须使用本次执行凭据,校验身份、职责、安排、运行代次和租约;自由房间的自动回答须同时携带成员凭证和有效执行编号。失效执行不能改作普通发言,持久成员凭证不能绕过自动讨论的执行检查。自由成员离开后凭证立即失效,请回时换发新凭证;讨论名单删去的成员同样停用。
8
8
 
9
9
  所有新房间在同一事务中登记房主及成员凭据。创建者先保存凭据再提交,同一建房编号的重试必须沿用原配置及全部凭据;服务端不返回明文凭据。浏览器在本地保存本房间的私有接入资料,下载成员文件时只导出该成员的 `site`、`room`、`token`。本机启动器另保存自己的私有配置;CLI 建房要求指定私有文件。不要提交这些文件到 Git、上传到房间或把 token 复制进指令。没有凭据登记的历史房间只读,本次不提供自动认领或迁移。
10
10
 
package/README.md CHANGED
@@ -29,7 +29,7 @@ Most multi-agent frameworks spin up fresh agents inside their own runtime. AI Co
29
29
  - **Delegate by mention** — `@` an agent and it works in its own original session; several can run in parallel. Unaddressed messages go to an AI host that routes the work.
30
30
  - **Structured discussions** — set a goal and round / time limits; the host schedules speakers and follow-ups, then the room returns to free collaboration with history and conclusions kept.
31
31
  - **Smart Scheduling** — optionally let a decision model handle routine routing instead of waking the host.
32
- - **Mix any participant** — local CLIs and desktop apps, model APIs, manually connected sessions and web chatbots in one room.
32
+ - **Mix any participant** — local CLIs and desktop apps, model APIs and manually connected sessions in one room.
33
33
  - **Shared workspace** — point a room at a project directory and grant each local agent read-only or writable access.
34
34
  - **Live roster** — invite, pause, recall or remove members at any time, including resuming an existing agent session.
35
35
  - **Portable records** — attachments, ZIP export / import between sites with discussion history, and sourced reports via the `discuss-report` skill.
@@ -52,7 +52,6 @@ It is opt-in per room and uses your own key; the room title and recent messages
52
52
  | **Auto-wake** | Claude Code CLI · Codex App / CLI · Antigravity App / CLI | Woken by the local service, resumes the original session, can host |
53
53
  | **Model API** | OpenAI · Claude · Gemini · DeepSeek · Qwen · Grok · OpenAI-compatible | Called by the local service with room tools only, can host |
54
54
  | **Manual** | Any local AI that can run `codiscuss` (e.g. Cursor CLI) | Reads and writes with its own member credential |
55
- | **Web relay** | Online chat products | Read-only; you relay replies to free-collaboration messages or scheduled discussion requests |
56
55
 
57
56
  ## Quick Start
58
57
 
@@ -93,7 +92,6 @@ flowchart LR
93
92
  R -. routing, optional .-> D[Decision model]
94
93
  A -- codiscuss CLI --> S
95
94
  M -- room tools --> S
96
- W[Web chatbots] -. read-only page, relayed replies .-> S
97
95
  ```
98
96
 
99
97
  - The **site** stores rooms, members and messages, and enforces turn order, revisions and credentials. It never runs models or stores API keys.
package/README.zh-CN.md CHANGED
@@ -29,7 +29,7 @@
29
29
  - **点名分工**:`@` 哪位 AI,它就在自己的原会话中处理,多位可并行;未点名的消息由 AI 主持人分派。
30
30
  - **结构化讨论**:设定目标与轮次 / 时长上限,主持人安排发言与追问;结束后回到自由协作,记录与结论保留。
31
31
  - **智能调度**:可选由决策模型处理日常分派,不必每次唤醒主持人。
32
- - **混合接入**:本地 CLI 与桌面 App、模型 API、手动接入的会话和在线网页 AI 可同房协作。
32
+ - **混合接入**:本地 CLI 与桌面 App、模型 API、手动接入的会话可同房协作。
33
33
  - **共享项目**:为房间指定项目目录,并为每位本地 AI 设置只读或可写。
34
34
  - **随时调整成员**:随时邀请、暂离、请回或移除成员,也可以续接一个已有的 AI 会话。
35
35
  - **可迁移记录**:附件、包含讨论历史的跨站点 ZIP 导出 / 导入,并可用 `discuss-report` 技能生成带来源的报告。
@@ -52,7 +52,6 @@
52
52
  | **自动唤醒** | Claude Code CLI · Codex App / CLI · Antigravity App / CLI | 由本机服务唤醒并续接原会话,可担任主持人 |
53
53
  | **模型 API** | OpenAI · Claude · Gemini · DeepSeek · Qwen · Grok · OpenAI 兼容服务 | 由本机服务调用,仅有房间工具,可担任主持人 |
54
54
  | **手动接入** | 任何可运行 `codiscuss` 的本地 AI(如 Cursor CLI) | 凭自己的成员凭证读写 |
55
- | **网页代发** | 在线聊天产品 | 只读;你可代发自由协作消息或讨论安排的回答 |
56
55
 
57
56
  ## 快速开始
58
57
 
@@ -90,7 +89,6 @@ flowchart LR
90
89
  R -. 调度判断(可选) .-> D[决策模型]
91
90
  A -- codiscuss CLI --> S
92
91
  M -- 房间工具 --> S
93
- W[网页 AI] -. 只读阅读页、代发回答 .-> S
94
92
  ```
95
93
 
96
94
  - **网站**保存房间、成员与消息,校验发言安排、版本与凭据;不运行模型,也不保存 API 密钥。
@@ -3,8 +3,7 @@
3
3
  A free room has no floor, turns or rounds. The user (holding the room control credential)
4
4
  names members; each named member gets one durable request per message. The local runner
5
5
  delivers a runner member's open requests as one execution, and the member's answer completes
6
- them. A manual member finds its own requests and answers by request id; an online member's
7
- answer is forwarded by the owner. The user is the client, never the host: one member that
6
+ them. A manual member finds its own requests and answers by request id. The user is the client, never the host: one member that
8
7
  answers by itself always hosts, routes unnamed messages, names members in its answers, and is
9
8
  woken once when everything it dispatched has finished. No software is launched here.
10
9
  """
@@ -92,8 +91,6 @@ MAX_RETRIES = 3
92
91
  MAX_RETRY_REASON = 500
93
92
  ACTIVE = ("dispatched", "started")
94
93
  ACCESS_MODES = ("runner", "manual")
95
- # A member given as `online` (older CLI room creation) becomes a manual member relayed from the web.
96
- LEGACY_ACCESS_MODES = ("runner", "manual", "online")
97
94
  SCHEDULER_PROVIDERS = ("jev", "decisions")
98
95
  RECOMMEND_SECONDS = 60
99
96
  # A click sent just before the hint disappears still counts.
@@ -106,7 +103,6 @@ MAX_CHAIN_ROUNDS = 20
106
103
  # A host that left gives up the role; the first active runner member (else manual) then takes it.
107
104
  RELEASE_HOST = "UPDATE room_members SET role='member' WHERE room_id=? AND role='host' AND status<>'active'"
108
105
  ASSIGN_HOST = """UPDATE room_members SET role='host' WHERE id=(SELECT id FROM room_members WHERE room_id=? AND status='active'
109
- AND NOT EXISTS(SELECT 1 FROM member_manual_sources source WHERE source.member_id=room_members.id AND source.source='web')
110
106
  ORDER BY access='runner' DESC, id LIMIT 1)
111
107
  AND NOT EXISTS (SELECT 1 FROM room_members WHERE room_id=? AND role='host' AND status='active')"""
112
108
  TERMINAL = ("completed", "cancelled", "failed")
@@ -147,9 +143,6 @@ def migrate_free_tables(connection):
147
143
  for name, definition in columns.items():
148
144
  if name not in present:
149
145
  connection.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
150
- connection.execute("""CREATE TABLE IF NOT EXISTS member_manual_sources (
151
- member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
152
- source TEXT NOT NULL CHECK(source='web'))""")
153
146
  migrate_stop_results(connection)
154
147
 
155
148
 
@@ -389,15 +382,10 @@ def member_value(member):
389
382
  if not isinstance(adapter, str) or len(adapter) > 40:
390
383
  raise ValueError("成员适配器名称无效")
391
384
  access, session_mode = member.get("access", "runner"), member.get("session_mode", "new")
392
- if access not in LEGACY_ACCESS_MODES or session_mode not in SESSION_MODES:
393
- raise ValueError("成员接入方式须为 runner/manual/online,会话方式须为 new/borrowed")
394
- manual_source = member.get("manual_source", "")
395
- if access == "online":
396
- manual_source, access = "web", "manual"
397
- if manual_source not in ("", "web") or (manual_source == "web" and access != "manual"):
398
- raise ValueError("manual_source 只能为 manual 成员配置 web")
385
+ if access not in ACCESS_MODES or session_mode not in SESSION_MODES:
386
+ raise ValueError("成员接入方式须为 runner/manual,会话方式须为 new/borrowed")
399
387
  return {"name": member["name"], "adapter": adapter, "token": member["token"],
400
- "access": access, "session_mode": session_mode, "manual_source": manual_source}
388
+ "access": access, "session_mode": session_mode}
401
389
 
402
390
 
403
391
  def review_request_context(connection, request):
@@ -439,10 +427,6 @@ def insert_member(connection, room_id, member, now):
439
427
  connection.execute("""INSERT INTO room_members (room_id,name,adapter,token_hash,access,session_mode,created_at)
440
428
  VALUES (?,?,?,?,?,?,?)""", (room_id, member["name"], member["adapter"], token_hash(member["token"]),
441
429
  member["access"], member["session_mode"], now))
442
- if member.get("manual_source") == "web":
443
- member_id = connection.execute("SELECT id FROM room_members WHERE room_id=? AND name=? AND token_hash=?",
444
- (room_id, member["name"], token_hash(member["token"]))).fetchone()[0]
445
- connection.execute("INSERT OR IGNORE INTO member_manual_sources(member_id,source) VALUES(?,'web')", (member_id,))
446
430
 
447
431
 
448
432
  def save_room_access(connection, room_id, control_token, members, settings):
@@ -462,10 +446,9 @@ def sync_participants(connection, room_id):
462
446
  """Keep one active AI host, then mirror member capabilities and the host into shared room metadata."""
463
447
  connection.execute(RELEASE_HOST, (room_id,))
464
448
  connection.execute(ASSIGN_HOST, (room_id, room_id))
465
- members = connection.execute("""SELECT m.name,m.access,m.role,m.status,COALESCE(s.source,'') AS manual_source
466
- FROM room_members m LEFT JOIN member_manual_sources s ON s.member_id=m.id
467
- WHERE m.room_id=? AND m.status<>'removed' ORDER BY m.id""", (room_id,)).fetchall()
468
- experts = {row["name"]: "online" if row["manual_source"] == "web" else "cli" for row in members}
449
+ members = connection.execute("""SELECT name,role,status FROM room_members
450
+ WHERE room_id=? AND status<>'removed' ORDER BY id""", (room_id,)).fetchall()
451
+ experts = {row["name"]: "cli" for row in members}
469
452
  host = next((row["name"] for row in members if row["role"] == "host" and row["status"] == "active"), "用户")
470
453
  connection.execute("UPDATE topic_settings SET experts=?,host=? WHERE room_id=?",
471
454
  (json.dumps(experts, ensure_ascii=False), host, room_id))
@@ -673,8 +656,7 @@ def possible_conflicts(connection, room_id, names):
673
656
 
674
657
 
675
658
  # An invited manual member that has not registered counts as absent: it receives no request and is never a target.
676
- REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_manual_sources source
677
- WHERE source.member_id=room_members.id AND source.source='web') OR EXISTS(SELECT 1 FROM member_registrations r
659
+ REGISTERED_MEMBER = """(room_members.access<>'manual' OR EXISTS(SELECT 1 FROM member_registrations r
678
660
  WHERE r.member_id=room_members.id AND r.token_hash=room_members.token_hash))"""
679
661
 
680
662
  # One snapshot for group routing; the Worker uses the same SQL inside its atomic write assertion.
@@ -696,8 +678,6 @@ def free_view(connection, room_id):
696
678
  members = []
697
679
  for row in connection.execute("SELECT * FROM room_members WHERE room_id=? AND status<>'removed' ORDER BY id", (room_id,)):
698
680
  member = {key: row[key] for key in MEMBER_FIELDS}
699
- source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
700
- member["manual_source"] = source[0] if source else ""
701
681
  member["registered_at"] = registered_at(connection, row)
702
682
  reported = connection.execute("SELECT session_id,client FROM member_sessions WHERE member_id=?", (row["id"],)).fetchone()
703
683
  member["reported_session"], member["reported_client"] = (reported["session_id"], reported["client"]) if reported else ("", "")
@@ -736,8 +716,6 @@ def require_member(connection, room_id, token, name=None):
736
716
  (room_id, hashed)).fetchone() if hashed else None
737
717
  if not row or (name is not None and row["name"] != name):
738
718
  raise PermissionError("成员凭证无效、已停用或与发言身份不符")
739
- if connection.execute("SELECT 1 FROM member_manual_sources WHERE member_id=? AND source='web'", (row["id"],)).fetchone():
740
- raise PermissionError("网页成员只读;由房主凭据代发其回答")
741
719
  return dict(row)
742
720
 
743
721
 
@@ -787,11 +765,7 @@ def authorize_free_write(connection, room_id, token, operation, payload):
787
765
  member = require_member(connection, room_id, token)
788
766
  if author == member["name"] and payload.get("submitted_by", author) == author:
789
767
  return member
790
- if member["role"] == "host" and author != member["name"] and payload.get("submitted_by") == member["name"]:
791
- proxy = proxy_member(connection, room_id, author)
792
- proxy["proxy_by"] = member["name"]
793
- return proxy
794
- raise PermissionError("成员只能以本人身份发言;只有主持人可以代发在线成员回答")
768
+ raise PermissionError("成员只能以本人身份发言")
795
769
 
796
770
 
797
771
  def active_members(connection, room_id, registered_only=True):
@@ -811,7 +785,7 @@ def recipients(connection, room_id, payload):
811
785
  """Resolve an owner message's recipients as (member id, purpose) pairs.
812
786
 
813
787
  A single active member is named by default. With several, an unnamed message goes to the
814
- AI host for routing; only a room of online members has none, and then it stays a plain note.
788
+ AI host for routing; a room without one leaves it a plain note.
815
789
  Naming everyone with order auto leaves the host a group request: the runner has everyone answer
816
790
  at once or one after another; a host the runner does not wake has them all answer at once.
817
791
  """
@@ -830,17 +804,6 @@ def recipients(connection, room_id, payload):
830
804
  return [(host["id"], "route")] if host else []
831
805
 
832
806
 
833
- def proxy_member(connection, room_id, author):
834
- """Return the active online member the owner may forward an answer for."""
835
- row = connection.execute("""SELECT m.*,s.source AS manual_source FROM room_members m
836
- JOIN member_manual_sources s ON s.member_id=m.id AND s.source='web'
837
- WHERE m.room_id=? AND m.name=? AND m.status='active' AND m.access='manual'""",
838
- (room_id, author)).fetchone()
839
- if not row:
840
- raise PermissionError("房间控制凭据只能以用户身份发言,或代发在线成员的回答")
841
- return dict(row)
842
-
843
-
844
807
  def check_answer(connection, room_id, member, payload):
845
808
  """A runner member completes a live execution; a manual member may close its own open requests.
846
809
 
@@ -891,20 +854,6 @@ def check_dispatch(member, payload, purposes):
891
854
  raise ValueError("这次是请全体成员各自回应,其他成员已收到请求;主持人只回答自己的部分,不能用 --to 派活")
892
855
 
893
856
 
894
- def check_proxy(connection, room_id, member, payload):
895
- """The owner forwards an online member's answer and may close that member's open requests."""
896
- request_ids = payload.get("request_ids", [])
897
- if not isinstance(request_ids, list) or any(type(item) is not int for item in request_ids):
898
- raise ValueError("request_ids 须为请求编号列表")
899
- requests = []
900
- for request_id in request_ids:
901
- row = connection.execute("SELECT * FROM member_requests WHERE room_id=? AND id=?", (room_id, request_id)).fetchone()
902
- if not row or row["member_id"] != member["id"] or row["status"] != "pending":
903
- raise FileExistsError(f"请求 {request_id} 不属于该在线成员或已处理")
904
- requests.append(row)
905
- discussion.validate_session_request_answer(connection, room_id, requests)
906
-
907
-
908
857
  def insert_request(connection, room_id, member_id, message_id, chain_id, purpose, created_by, batch_id=0):
909
858
  """Queue one request; every request belongs to the chain its owner message started."""
910
859
  connection.execute("""INSERT INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
@@ -1107,11 +1056,11 @@ def advance_queues(connection, room_id):
1107
1056
 
1108
1057
  Asked rows stay, so the speaking order can still show who already answered.
1109
1058
  """
1110
- connection.execute(f"""DELETE FROM free_queue WHERE room_id=? AND NOT {QUEUE_ASKED.format(row="free_queue")}
1111
- AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=free_queue.member_id AND m.status='active')""", (room_id,))
1112
- connection.execute(f"""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
1113
- SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue q
1114
- WHERE q.room_id=? AND q.id=(SELECT MIN(x.id) FROM free_queue x WHERE x.message_id=q.message_id AND NOT {QUEUE_ASKED.format(row="x")})
1059
+ connection.execute("""DELETE FROM free_queue WHERE id IN (SELECT q.id FROM free_queue_waiting q WHERE q.room_id=?
1060
+ AND NOT EXISTS(SELECT 1 FROM room_members m WHERE m.id=q.member_id AND m.status='active'))""", (room_id,))
1061
+ connection.execute("""INSERT OR IGNORE INTO member_requests (room_id,member_id,message_id,chain_id,batch_id,purpose,created_by,created_at)
1062
+ SELECT q.room_id,q.member_id,q.message_id,q.message_id,0,'ask','user',? FROM free_queue_waiting q
1063
+ WHERE q.id IN (SELECT MIN(id) FROM free_queue_waiting WHERE room_id=? GROUP BY message_id)
1115
1064
  AND NOT EXISTS(SELECT 1 FROM member_requests r WHERE r.message_id=q.message_id AND r.status IN ('pending','delivering'))""",
1116
1065
  (int(time.time()), room_id))
1117
1066
 
@@ -1142,8 +1091,11 @@ def speaking_order(connection, room):
1142
1091
  target = session["id"]
1143
1092
  items, later, session_plan_state = discussion.session_speaking_order(connection, session)
1144
1093
  else:
1145
- target = connection.execute("""SELECT MAX(q.message_id) FROM free_queue q LEFT JOIN member_requests r ON r.message_id=q.message_id
1146
- AND r.member_id=q.member_id WHERE q.room_id=? AND (r.id IS NULL OR r.status IN ('pending','delivering'))""", (room["id"],)).fetchone()[0] or 0
1094
+ target = connection.execute("""SELECT MAX(message_id) FROM (
1095
+ SELECT MAX(message_id) AS message_id FROM free_queue_waiting WHERE room_id=?
1096
+ UNION ALL SELECT MAX(r.message_id) FROM member_requests r WHERE r.room_id=? AND r.status IN ('pending','delivering')
1097
+ AND EXISTS(SELECT 1 FROM free_queue q WHERE q.message_id=r.message_id AND q.member_id=r.member_id))""",
1098
+ (room["id"], room["id"])).fetchone()[0] or 0
1147
1099
  if target:
1148
1100
  kind = "queue"
1149
1101
  for row in connection.execute("""SELECT q.member_id,r.status FROM free_queue q LEFT JOIN member_requests r
@@ -1215,11 +1167,6 @@ def after_free_message(connection, room_id, message_id, actor, payload):
1215
1167
  for member_id, purpose in pairs:
1216
1168
  insert_request(connection, room_id, member_id, message_id, message_id, purpose, "user")
1217
1169
  return
1218
- if actor.get("manual_source") == "web":
1219
- for request_id in payload.get("request_ids", []):
1220
- connection.execute("UPDATE member_requests SET status='completed',answer_id=? WHERE id=?", (message_id, request_id))
1221
- settle_batches(connection, room_id)
1222
- return
1223
1170
  chain_id = answered_chain(connection, payload, actor)
1224
1171
  if actor["access"] == "manual":
1225
1172
  for request_id in payload.get("request_ids", []):
@@ -1331,7 +1278,7 @@ def inbox(connection, room_id, token):
1331
1278
  release_recommendations(connection, room_id)
1332
1279
  archived = connection.execute("SELECT archived FROM room_directory WHERE room_id=?", (room_id,)).fetchone()[0]
1333
1280
  members, observers = [], []
1334
- # Manual and online members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1281
+ # Manual members are never delivered to; the runner only reads them so smart scheduling can route to them too.
1335
1282
  for row in connection.execute(f"SELECT * FROM room_members WHERE room_id=? AND status='active' AND {REGISTERED_MEMBER} ORDER BY id", (room_id,)):
1336
1283
  pending = [dict(item) for item in connection.execute("""SELECT r.id,r.message_id,r.purpose,
1337
1284
  sr.id AS session_request_id,sr.session_id,sr.round,sr.position,sr.read_until,sr.question AS source_question,
@@ -1340,9 +1287,7 @@ def inbox(connection, room_id, token):
1340
1287
  FROM member_requests r LEFT JOIN session_requests sr ON sr.member_request_id=r.id
1341
1288
  LEFT JOIN task_message_sources t ON t.session_request_id=sr.id
1342
1289
  WHERE r.member_id=? AND r.status='pending' ORDER BY r.id""", (row["id"],))]
1343
- manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (row["id"],)).fetchone()
1344
1290
  member_view = {key: row[key] for key in MEMBER_FIELDS}
1345
- member_view["manual_source"] = manual_source[0] if manual_source else ""
1346
1291
  member_view["review_capability"] = review_capability(connection, room_id, row["id"], token)
1347
1292
  member_view["held_until"] = held_until(connection, row["id"])
1348
1293
  # The runner compares this with its adapter's effect and reports a change.
@@ -1817,8 +1762,8 @@ def interrupt_member(connection, room_id, member_id, token):
1817
1762
 
1818
1763
  def withdrawals(connection, room_id):
1819
1764
  """The room's taken-back messages by id, so the page keeps them in place while AI readers skip them."""
1820
- return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawals w
1821
- JOIN messages m ON m.id=w.message_id WHERE m.room_id=? ORDER BY w.message_id""", (room_id,))]
1765
+ return [dict(row) for row in connection.execute("""SELECT w.message_id,w.kind FROM message_withdrawal_rooms r
1766
+ JOIN message_withdrawals w ON w.message_id=r.message_id WHERE r.room_id=? ORDER BY r.message_id""", (room_id,))]
1822
1767
 
1823
1768
 
1824
1769
  def owner_message(connection, room_id, message_id):
@@ -1867,7 +1812,7 @@ def rewind_plan(connection, room_id, message_id):
1867
1812
  """What rewinding to just before a message takes back, and a revision of the state it was read from.
1868
1813
 
1869
1814
  Each member reports what happens to its session: `cut` (the runner cuts it before the first taken-back execution),
1870
- `fresh` (its context is rebuilt each time), `unchanged` (no taken-back execution) or `kept` (manual and online members,
1815
+ `fresh` (its context is rebuilt each time), `unchanged` (no taken-back execution) or `kept` (manual members,
1871
1816
  and software the runner reported it cannot cut, keep what they saw). The runner reports each member's effect from its
1872
1817
  adapter (`member_capabilities`); a member without a report is kept.
1873
1818
  """
@@ -2008,8 +1953,6 @@ def member_action(connection, room_id, member_id, token, payload):
2008
1953
  `role` action makes one runner or manual member the AI host, replacing any previous host.
2009
1954
  """
2010
1955
  member = owned_member(connection, room_id, member_id, token)
2011
- manual_source = connection.execute("SELECT source FROM member_manual_sources WHERE member_id=?", (member_id,)).fetchone()
2012
- is_web_member = bool(manual_source and manual_source["source"] == "web")
2013
1956
  action = payload.get("action")
2014
1957
  routed = []
2015
1958
  if action in ("away", "remove"):
@@ -2038,8 +1981,8 @@ def member_action(connection, room_id, member_id, token, payload):
2038
1981
  adopt_manual_member(connection, room_id, member, payload)
2039
1982
  elif action == "role":
2040
1983
  role = payload.get("role")
2041
- if role not in ("host", "member") or (role == "host" and is_web_member):
2042
- raise ValueError("role 须为 host 或 member;在线成员不能担任主持人")
1984
+ if role not in ("host", "member"):
1985
+ raise ValueError("role 须为 host 或 member")
2043
1986
  if role == "member" and member["role"] == "host":
2044
1987
  raise ValueError("自由房间须由一位 AI 主持;请指定另一位成员担任主持人")
2045
1988
  if role == "host":
@@ -9,6 +9,66 @@
9
9
  -- the room. Existing member credentials keep their permissions.
10
10
  PRAGMA defer_foreign_keys = ON;
11
11
 
12
+ -- This script runs before schema.sql, so a database older than these tables gets them here; same definitions as schema.sql.
13
+ CREATE TABLE IF NOT EXISTS room_modes (
14
+ room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
15
+ mode TEXT NOT NULL CHECK(mode='free'),
16
+ control_hash TEXT NOT NULL DEFAULT ''
17
+ );
18
+ CREATE TABLE IF NOT EXISTS room_members (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
21
+ name TEXT NOT NULL,
22
+ adapter TEXT NOT NULL DEFAULT '',
23
+ role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','host')),
24
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','away','removed')),
25
+ token_hash TEXT NOT NULL,
26
+ read_until INTEGER NOT NULL DEFAULT 0,
27
+ created_at TEXT NOT NULL,
28
+ access TEXT NOT NULL DEFAULT 'runner',
29
+ session_mode TEXT NOT NULL DEFAULT 'new'
30
+ );
31
+ CREATE TABLE IF NOT EXISTS member_manual_sources (
32
+ member_id INTEGER PRIMARY KEY REFERENCES room_members(id) ON DELETE CASCADE,
33
+ source TEXT NOT NULL CHECK(source='web')
34
+ );
35
+ CREATE TABLE IF NOT EXISTS discussion_sessions (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
38
+ goal TEXT NOT NULL,
39
+ status TEXT NOT NULL DEFAULT 'paused' CHECK(status IN ('active','paused','concluded')),
40
+ started_message_id INTEGER NOT NULL DEFAULT 0,
41
+ ended_message_id INTEGER NOT NULL DEFAULT 0,
42
+ created_at TEXT NOT NULL
43
+ );
44
+ CREATE TABLE IF NOT EXISTS session_state (
45
+ session_id INTEGER PRIMARY KEY REFERENCES discussion_sessions(id) ON DELETE CASCADE,
46
+ phase TEXT NOT NULL CHECK(phase IN ('initial','discussing')),
47
+ revision INTEGER NOT NULL DEFAULT 0,
48
+ read_until INTEGER NOT NULL DEFAULT 0,
49
+ held_since INTEGER NOT NULL DEFAULT 0
50
+ );
51
+ CREATE TABLE IF NOT EXISTS attachments (
52
+ id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
53
+ message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
54
+ position INTEGER NOT NULL DEFAULT 0,
55
+ file_name TEXT NOT NULL, content_type TEXT NOT NULL,
56
+ size INTEGER NOT NULL, sha256 TEXT NOT NULL,
57
+ actor TEXT NOT NULL, submission_id TEXT NOT NULL, created_at TEXT NOT NULL,
58
+ UNIQUE(room_id, submission_id)
59
+ );
60
+ CREATE TABLE IF NOT EXISTS execution_segments (
61
+ source TEXT NOT NULL CHECK(source='free'),
62
+ execution_id INTEGER NOT NULL,
63
+ seq INTEGER NOT NULL,
64
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
65
+ phase TEXT NOT NULL,
66
+ tool TEXT NOT NULL DEFAULT '',
67
+ start_ms INTEGER NOT NULL,
68
+ end_ms INTEGER NOT NULL,
69
+ PRIMARY KEY(source,execution_id,seq)
70
+ );
71
+
12
72
  -- Every room is a collaboration room; members given as online are manual members relayed from the web.
13
73
  UPDATE room_modes SET mode='free' WHERE mode<>'free';
14
74
  INSERT OR IGNORE INTO room_modes (room_id,mode,control_hash) SELECT id,'free','' FROM rooms;
@@ -20,6 +20,8 @@ from .admin import AdminError
20
20
  from .runtime import begin_write
21
21
 
22
22
  MACHINE_ONLINE_SECONDS = 30
23
+ # Heartbeats refresh seen_at at most this often (well inside the online window) so frequent polls do not each write a row.
24
+ MACHINE_SEEN_REFRESH_SECONDS = 10
23
25
  MACHINE_NAME_MAX = 60
24
26
  PROJECT_NAME_MAX = 80
25
27
  PROJECTS_MAX = 100
@@ -135,11 +137,15 @@ def machine_sync(connection, machine_id, token, payload, now):
135
137
  projects, clients = valid_projects(payload.get("projects")), valid_clients(payload.get("clients"))
136
138
  public_key = valid_public_key(payload["public_key"]) if "public_key" in payload else None
137
139
  begin_write(connection)
138
- connection.execute("UPDATE local_machines SET projects=?, clients=?, seen_at=? WHERE id=?",
139
- (json.dumps(projects, ensure_ascii=False), json.dumps(clients, ensure_ascii=False), now, machine_id))
140
+ # Unchanged heartbeats write nothing until seen_at is due for a refresh.
141
+ connection.execute("UPDATE local_machines SET projects=?1, clients=?2, seen_at=?3 WHERE id=?4 "
142
+ "AND (projects IS NOT ?1 OR clients IS NOT ?2 OR seen_at<?3-?5)",
143
+ (json.dumps(projects, ensure_ascii=False), json.dumps(clients, ensure_ascii=False), now, machine_id,
144
+ MACHINE_SEEN_REFRESH_SECONDS))
140
145
  if public_key:
141
146
  connection.execute("INSERT INTO machine_keys (machine_id,public_key) VALUES (?,?) "
142
- "ON CONFLICT(machine_id) DO UPDATE SET public_key=excluded.public_key", (machine_id, json.dumps(public_key)))
147
+ "ON CONFLICT(machine_id) DO UPDATE SET public_key=excluded.public_key "
148
+ "WHERE machine_keys.public_key IS NOT excluded.public_key", (machine_id, json.dumps(public_key)))
143
149
  rows = connection.execute("SELECT id,request FROM remote_launches WHERE machine_id=? AND status IN ('pending','claimed') ORDER BY created_at",
144
150
  (machine_id,)).fetchall()
145
151
  return {"launches": [{"id": row["id"], "request": json.loads(row["request"])} for row in rows]}
@@ -245,7 +251,7 @@ def claim_machine_commands(connection, machine_id, token, now):
245
251
  require_machine(connection, machine_id, token)
246
252
  begin_write(connection)
247
253
  connection.execute("DELETE FROM machine_commands WHERE machine_id=? AND created_at<?", (machine_id, now - COMMAND_TTL_SECONDS))
248
- connection.execute("UPDATE local_machines SET seen_at=? WHERE id=?", (now, machine_id))
254
+ connection.execute("UPDATE local_machines SET seen_at=? WHERE id=? AND seen_at<?", (now, machine_id, now - MACHINE_SEEN_REFRESH_SECONDS))
249
255
  rows = connection.execute("SELECT id,request FROM machine_commands WHERE machine_id=? AND status='pending' ORDER BY created_at",
250
256
  (machine_id,)).fetchall()
251
257
  for row in rows: