Skip to content

v5.4.0 — 后台处理:到线自己压,压完把宪法装回来

Choose a tag to compare

@MirHuang666 MirHuang666 released this 09 Nov 11:05
· 17 commits to main since this release

本页会随勘误就地更新。规范全文以 main 为准。

这一版只回答一个问题:上下文快满了,谁先动手?

在这之前,是我盯着水位、想起来的时候自己敲 /compact。这一版之后,是程序自己量、自己敲——
而且它敲的那一下,跟我敲的是同一个东西。

一、设计思想

三条原则,按重要性排。

1. 压归壳,不归我们。

压缩要读完整历史、判断哪段能丢、再写成摘要——只有壳自己干得对,因为它手里才有完整对话。
我们自研一套压缩,等于在没有原文的地方做摘要,压完必然对不上账:摘要里写的,原文可能没有;
原文有的,摘要里丢了。

所以这一版一行压缩算法都没写。我们只是把壳已有的 /compact 替人敲进去。
上一版把压缩标成 PENDING,理由是"壳的压缩接口没有稳定契约"——这个判断没错,
错的是问错了问题:不需要契约,只需要一只手。

2. 到线才动,清之前先落盘。

上下文不是垃圾,是还没归档的现场。清得太早,等于把还有用的东西扔掉;清得太晚,一次请求被拒,整轮白干。
所以线是硬的,而且顺序不可调换:镜像线(300K)永远早于压缩线(350K)——先落盘,再让壳压。

这条线今天重排过一次。原来的梯子是 300K / 600K / 800K,可壳自己的压缩线是
0.35 × 1,048,576 ≈ 367K:人根本活不到 800K,要么中途手动压了,要么先被壳压掉。
于是"到 800K 先镜像再压"变成一条只在纸上成立的规则——判据①挂了一个多月没发生,就是这么来的。
梯子现在按真实到得了的线排:

150K        250K        300K              350K        367K         1M
 |           |           |                 |           |            |
 注意        备包        镜像→出包         我们敲压     壳自动线      断
                          (原文先落盘)       (冷却900s)

而且"先落盘"不是写在纸上的顺序,是硬闸:镜像/校验失败,ensure_mirror() 直接返回 False,
程序退出、日志记 no-mirror,这一轮就是不压。宁可这轮不压,也不能压完才发现正文没了。

顺带纠正一处只有实现才会暴露的细节:镜像不能复用半小时前那份导出的副本,
差的这几十分钟正是刚聊出来的内容——把它压掉,等于把最该留的丢了。所以每次开火都现导(1–2 秒)。

3. 自动不等于替人决定。

程序可以自己量、自己按,但不许删记忆、不许改库。压缩只作用于当前会话的上下文,
sessions\ 与 archive\ 里的原文一个字都不动。删除永远要人点头——这是红线,不随版本变。

还有一条是今天踩出来的,写进来防下次再踩:闹钟不能挂在会话里。
第一版把这 5 分钟一次的检查登记在会话自己的调度器上,结果压缩一发生、会话被换掉,
闹钟跟着一起消失(事后查 schedule_list 是空的)。能穿越会话的只有系统级计划任务。

3. 压完不算完:宪法要装回来。

压缩是有损的——旧历史变成摘要,摘要里不会有"仲裁梯子""必须人点头的六件"。
上下文一轻,规则也跟着轻,下一轮就容易用"模型默认倾向"顶替"人的当场指令"。

所以这一版还有第二棒:设定重载器(Constitution Reloader)。它和压缩是一对——
压缩负责放下,重载负责留住。

而且它必须是独立进程(他的原话:「5 分钟重读设定和心跳不是一回事,两个是分开的」):
心跳(30 秒)管"系统还活着",重载管"宪法还在不在"。塞进心跳,重载一卡(等焦点、写文件)
心跳就跟着慢——救命的和续命的不许共用一条命。

4. 重载按水位带,不按时间。

第一版我按"5 分钟一次"做,被指出来不对。对的地方在于:记忆散掉跟"带进来多少"相关,跟"过了几分钟"无关。
所以改成水位带:150K 起,每涨 100K 补一次;回落到 150K 以下自动重置。
闲置一小时一次不灌,猛聊二十分钟到带就补。

二、步骤

  每 5 分钟(Windows 计划任务 SysPersona-Compact,pythonw,无窗)
        │
        ▼
  persona_compact.py check
        │
        ├─ ① 量:gauge 读出一个数「[上下文] 377K」
        │        (DSH 增量:ΔcacheRead+Δinput / Δcalls —— 不是那个 56M 的累计缓存)
        │
        ├─ ② 判线:377K ≥ 350K ?
        │        └─ 不到 → 记 idle,收工。不打扰任何人,也不写第二条状态
        │
        ├─ ③ 判冷却:距上次开火 < 900 秒 ?
        │        └─ 是 → 记 cooldown,收工。一次压缩后水位要过一会儿才落,期间不许连发
        │
        ├─ ④ 先落盘:现导当前会话 → 镜像进 archive\ → 校验 sha256
        │        └─ 落不了盘 → 记 no-mirror,**这轮不压**(硬闸,不是提示)
        │
        ├─ ⑤ 不打断:GetLastInputInfo —— 他最近 5 秒还在敲,就等
        │
        ├─ ⑥ 敲:找壳主窗口 → 点输入框 → 剪贴板塞 `/compact`(8 字符)→ Ctrl+V → 回车 → 还原剪贴板
        │
        ├─ ⑦ 壳自己压:旧历史变成摘要,旧上下文不再随请求上送
        │
        └─ ⑧ 留痕:ops/compact.jsonl(fired / idle / cooldown / no-mirror)+ ops/compact-state.json(last_fire, last_ctx)
                 │
                 ▼
        下一轮 gauge 读到的是回落后的水位(377K → 18K)

一条闭环里只有 ⑥ 在动界面,其余七步全是读文件、导文件、算数、写日志。
这也是它能一天之内接上的原因:那些步骤在前几个版本里都已经跑熟了。

二·五、重载这一棒怎么走

  ① 水位带到了(150K 起,每 +100K 一次)
        │
        ├─ ② 要不要重载:水位带到了吗?设定 hash 变了吗?被标记了吗?
        │        └─ 都没到 → 记 skip,收工(不打扰)
        │
        ├─ ③ 不抢人:他最近 5 秒在敲 → 排队(ops/reloader-queue.json),不注入
        │
        ├─ ④ 不撞自己:会话文件 15 秒不长为空闲 → 才动
        │
        ├─ ⑤ 现抽:从 memory\设定\ 里抽仲裁梯子 / 人点头六件 / 交互铁律 / 每回合四步
        │        (现抽,不手抄——手抄的那份一定会过期)
        │
        ├─ ⑥ 注入(≤1600 字):点输入框 → 剪贴板 → Ctrl+V → 回车 → 还原剪贴板
        │
        └─ ⑦ 记账 + 推进水位带:ops/reloader.jsonl、ops/reloader.json(next_at = 当前 + 100K)

三、要实现什么

目标 对应机制 做到什么算完成
不靠人记得压 系统计划任务每 5 分钟一次 水位到线后,5 分钟内一定被处理过
量得准 直接读壳的 contextPressure,不自己推 托盘、压缩、壳内部判定同一个数(压力口径 147K / 界面口径 86K 分得清)
压得对 只敲 /compact,让壳压自己的历史 压完水位真的回落(377K → 18K),且对话仍能继续
不连压 冷却 900 秒 + 状态文件 刚压过再跑,只记 cooldown,不发第二条
压前必落盘 ensure_mirror() 现导 → 镜像 → 校验 sha256,失败即退出 镜像失败时一条 /compact 都不会发(不是靠自觉)
不打断人 GetLastInputInfo,5 秒内有输入就等 他打字时不会突然出现被回车发出去的半句
可复盘 两个日志 + 一个状态文件 每次开火或跳过,都有时间与当时水位
穿越会话 闹钟挂在系统计划任务上,不挂会话 压缩把会话换掉之后,检查仍然每 5 分钟在跑
压完把宪法装回来 独立的设定重载器,压缩成功后直接叫它 压缩后一定有一次重载,且不依赖我记不记得
重载不白灌 按水位带(150K 起 +100K),不按时间 闲置一小时 0 次灌入;到带才补
重载不打断人、也不撞自己 GetLastInputInfo + wait_shell_idle() 他打字时排队;我生成中不会被吞回车
重载是真读了
eread --ack 记下五份设定的 sha256 说"已重载"必须对得上 hash

四、细节

按职责分六组。每组先说为什么这么做。

1. 量——为什么不再自己算

最早的读法是账本里的 cacheRead(整个会话累计命中量,实测见过 97M)——拿累计量当水位,
人会以为还有 97M 可用,然后下一次请求直接被拒。改成增量 Δ(cacheRead+input)/Δcalls 后好了一些,
但它仍然是一段时间内的平均值:窗口里只要有一次压缩前的大调用,平均值就被拉高。
实测对不上:压缩刚发生时报 18K,过了一会儿报 130K,而壳自己记的是 139K。

所以最后一步是承认这个数不该由我们算。壳在 session_projcache 里每步都在写 contextPressure:

  • pressureTokens——最近一次调用的真实 prompt 量,壳判"要不要压缩"用的就是它;
  • surfaceTokens——界面上「消息占用」那个数,人眼看到的是它。

现在 gauge 直接读这两个,判定用 pressure、解释用 surface,一个数只有一个来源。

2. 判——为什么要两个门槛,不是一个

只有"到线"一个判断,会让程序在冷却期内反复开火:一次压缩后水位要过一会儿才落,
这期间每 5 分钟量到的还是 350K 以上,于是连发 /compact,把对话搅乱。
所以是"到线 + 冷却已过"两个条件同时成立才动手,冷却 900 秒,开火时间记在状态文件里。

3. 落盘——为什么压之前要先自己复制一份

壳的压缩是有损的:旧历史换成摘要,原文就不在对话里了。摘要是它写的,我们改不了、也验证不了。
所以"清之前先落盘"不是流程讲究,是唯一的补救:落盘的那份才算证据。
这一版把它做成了硬闸——ensure_mirror() 现导当前会话(1–2 秒)→ 镜像进 archive\ →
用 persona_mirror.py 校验 sha256 → 任何一步失败就 return 1,日志记 no-mirror。
反过来说,只要这一版压过,archive 里就一定有一份对得上的原文。

4. 敲——为什么要先看点没点、先看人在不在
壳的输入框在 Electron 的 UIA 树里读不到(只有 13 个元素),所以点击位置是算出来的几何点位:
(left + w/2, bottom - max(70, h/12))。点偏了,粘贴就落在别处,回车等于乱发。
所以流程是:点 → 剪贴板 → Ctrl+V → 回车,最后还原剪贴板(不毁掉他自己复制的东西)。
再加一道:他最近 5 秒内有键鼠输入就等一会——正在打字时把 /compact 敲进去并回车,
等于替他发了半句话。

4. 记——为什么失败也要记

失败不重试(下一轮 5 分钟后自然再试),但必须留痕。最坏的失效不是报错,是"发了但水位不降"——
壳升级改了命令口径时就是这样。这时日志里会留下连续 fired 而水位不降的证据,一眼能查;
静默失效则要等到某次请求被拒才发现。

5. 挂——为什么从会话闹钟改成系统计划任务

见设计思想第 3 条末段。补充一个实现细节:任务用 pythonw 起,配合 persona_win.py 给所有子进程加
CREATE_NO_WINDOW,所以每 5 分钟跑一次也不弹黑窗。

顺带修掉一个只在计划任务里出现的编码坑:gauge 子进程在计划任务上下文里按 GBK 吐中文,
按 utf-8 硬解会得到一串 U+FFFD,于是 [上下文] 对不上、误报「没有输出水位」。
现在改成收字节、两种编码都试,正则也不再依赖中文——只认"方括号后面的第一个数字"。

五、证据

  1. 真实压了一次,没人按:ops/compact.jsonl 记
    {"at":"2026-11-10 09:26:45","ctx":379000,"action":"fired"} —— 379K ≥ 350K,冷却已过,程序自己注入 8 个字符。
  2. 压完水位真的掉了:同一时刻前后,persona_gauge.py 从 377K 掉到 18K
    (下一轮读数 63K,随对话继续增长)。这一轮之后的对话开场即压缩摘要——旧历史确实被换掉了。
  3. 注入通道有独立留痕:ops/inject.jsonl 同一时间戳多一行 {"len":8,"dry":false,"ok":true,"src":"compact"}。
  4. 防抖可验:check --dry-run 在回落后返回 SKIP 水位 63K < 线 350K,不压,退出码 0。
  5. 计划任务真的在跑:任务 SysPersona-Compact 首次执行后 LastRunTime 有值、退出码 0,
    且不是会话级闹钟(schedule_list 里没有它)。
  6. 压前落盘真的会发生:ensure_mirror() 现导 → archive\2026-11-10\电脑会话\续接前-20261110-0938-mirror.md
    (231 KB),persona_mirror.py --check 回 OK … 一致(sha256 与原文相符)。
  7. 硬闸真的会拦:把 node 指到不存在的路径再跑,ensure_mirror() 返回
    (False, '导出异常…') —— 这条链上不会有任何 /compact 被发出去。
  8. 重载真的自己跑过一次:ops/reloader.jsonl 记
    10:23:27 periodic fired 已注入 870 字,触发原因是 水位 306K ≥ 带 150K;
    同一份日志里 10:20:29 与 10:22:36 两次都是 skip 不需要 —— 带内不灌。
  9. 不撞自己:10:15:38 那次 /compact 注入返回 ok,但水位没降——因为撞上我生成中的一轮,回车被吞。
    加 wait_shell_idle() 之后再没出现过;同时 reloader 那次正确记了 queued:他最近 5 秒在操作。
  10. 水位的"下一带"会自己推进:reloader --status → 水位 311K;下次重载未过带:409K;上次重载 10:23:27。8. 读数与壳同源:壳 contextPressure 记 pressureTokens 139,327 / surfaceTokens 79,875(同一时刻采样),
    gauge 输出的就是这两个数;改之前它给的是推出来的 130K。

六、边界

  • 不自研压缩算法,这一版没有、以后也不打算有——壳手里才有完整历史。
  • 重载的正文上限 1600 字:超了会截断并注明"全文在 memory\设定"。超长的那部分确实没进对话。
  • 水位带的两个数(150K / 100K)是我定的,理由是"壳压缩线 367K、交接线 300K,150K 大致是注意力开始散的地方"。
    它只在 persona_constitution_reloader.py 一处(CTX_FIRST / CTX_BAND),要改随时改。
  • 重载不改任何文件:它只读设定 + 注入一段文字;不删记忆、不动内核、不碰原文。- 摘要实体校验还没做:archive 里有原文,但"摘要里该有的东西是否都还在"没有自动比对。
    现在只能靠人(我)在换窗口时读接续包时发现——这不算机制。
  • /compact 是壳的实验特性(协议项 compaction_trigger 标 @experimental,且必须是请求的最后一项输入)。
    壳升级改口径,我们就会失效——失效表现是"发了但水位不降",日志留证,不静默。
  • 托盘还没有「立即压缩」手动项:手动仍然靠人在输入框敲 /compact。
  • "到线"依赖 DSH 自己的会话文件:换一个不把上下文写进 ~/.dsh 的客户端,这条链就得重接。
  • 压缩只压上下文,不动 sessions\ / archive\ 里的原文;删除记忆永远要人点头。

修订记录:2026-11-10 首版;同日补「压前必镜像」硬闸与对应证据(步骤、细节、证据、边界已同步)。