weflow-cli 1.7.0 → 1.8.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.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1949 -1
- package/OPERATIONS.md +774 -1
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +934 -27
- package/dist/bin/weflow-cli.js +952 -27
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -98
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,23 +4,1971 @@ The npm package is published separately from GitHub. It may lag behind the `mast
|
|
|
4
4
|
|
|
5
5
|
All notable user-facing changes are recorded here. This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 1.8.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The assistant can walk the knowledge graph more than one hop at a time.** `search_knowledge` used to return
|
|
12
|
+
the page plus its **direct** neighbours, and the tool description told the model it could "walk them one at a
|
|
13
|
+
time" - which meant deciding, on its own, which neighbour to look up next. It now takes a `depth` (default 1,
|
|
14
|
+
max 3) and returns the layers: from `RAG`, one hop is `向量化模型` / `LangChain` / `大语言模型` /
|
|
15
|
+
`上下文窗口`, and two hops reaches `向量数据库` / `Milvus` / `语义搜索`. Measured on the real vault: `RAG` has 4
|
|
16
|
+
one-hop neighbours and the second layer is reachable only through them.
|
|
17
|
+
|
|
18
|
+
Three rules, each because the obvious version misbehaves: **deduplicate** (a concept reachable by several paths
|
|
19
|
+
keeps only the first arrival, since BFS guarantees that is the shortest - otherwise the same name shows up at
|
|
20
|
+
several depths), **cap the nodes** (4 neighbours × 4 × 4 is 64; an uncapped third hop drowns the output, and a
|
|
21
|
+
graph the model cannot finish reading is worthless - and when it truncates it **says so**), and keep counting
|
|
22
|
+
neighbours that have no page yet rather than swallowing them.
|
|
23
|
+
|
|
24
|
+
Pinned by a scenario that asserts the **arguments**, not the tool name: `argsMatch: /depth=2/` - because
|
|
25
|
+
"called `search_knowledge`" and "called it with two hops" are different things. That is what the field is for,
|
|
26
|
+
and its format is `key=value` (the existing `time-window` uses `/since=/`), not JSON - my first attempt wrote
|
|
27
|
+
`/"depth"\s*:\s*2/` and the scenario duly failed while the assistant was doing exactly the right thing. The
|
|
28
|
+
soft assertion deliberately does not name a specific concept: asking about `RAG` actually matches `2-Step RAG`
|
|
29
|
+
(the name match takes the first hit), so what the second hop returns is not predictable - it checks that the
|
|
30
|
+
layers were laid out at all. Full evaluation **23/23**.
|
|
31
|
+
|
|
32
|
+
- **The chat cards are now in the Vault, under `Sources/Chat/`, and the assistant can search them.** They were only
|
|
33
|
+
in `output/chat-notes/`, which meant a person browsing Obsidian could see the chat *concepts* but never the
|
|
34
|
+
conversation that produced them (the timeline, the people, what is owed) - and, measurably, **1,176 of the chat
|
|
35
|
+
concept pages' "source" links pointed at nothing** plus **30 pointed at the page itself**. Both numbers are now
|
|
36
|
+
zero (1,822 resolve).
|
|
37
|
+
|
|
38
|
+
**The cards had to be renamed on the way in**: a chat card is named after its conversation (`白马非马`,
|
|
39
|
+
`老表亲戚群`), and a concept page can carry the same name. Two files with the same stem make `[[白马非马]]`
|
|
40
|
+
ambiguous in Obsidian, which would have broken links that currently work - so the Vault copies carry a
|
|
41
|
+
`会话-` prefix, and both the copy step and the link fixer read that prefix **from one shared constant**
|
|
42
|
+
(`_utils.CHAT_CARD_PREFIX`), because two copies of it is exactly the "same fact written twice" failure this repo
|
|
43
|
+
keeps hitting. `compile_wiki --fix-source-links` rewrote 1,825 links across 1,520 pages; `vault_search
|
|
44
|
+
--type note` now reads `Sources/Chat` too, so "what did we talk about in that group" reaches the cards.
|
|
45
|
+
|
|
46
|
+
- **The chat line is now part of the knowledge graph, and it arrives as a *people* graph.** `chat-notes` over a
|
|
47
|
+
400-day window produced **132 conversation cards** (one call per conversation) and `wiki compile` turned them
|
|
48
|
+
into **1,519 concept pages** - `Wiki/Concepts` went from 2,115 to **3,634 pages**, and the graph from 4,162 to
|
|
49
|
+
**5,538 concept-to-concept edges**. The chat corpus holds **572 distinct people** and **992 topics**, and only 4
|
|
50
|
+
concepts overlap with the article line - a conversation is a genuinely different knowledge space from a feed of
|
|
51
|
+
articles, and its natural nodes are people (龙老师 ×31, 耿哥 ×21, 闫学昊师兄 ×17, 董老师 ×15 …). The pages carry
|
|
52
|
+
`来源/聊天`, which the graph view already colours separately from `来源/文章`, so the two sources are one graph
|
|
53
|
+
and still tellable apart.
|
|
54
|
+
|
|
55
|
+
**The first attempt at this was wrong in a way worth recording: it read 19% of the text and said nothing.**
|
|
56
|
+
`NOTE_MESSAGES = 120` was a per-conversation cap, so across 138 conversations holding 80,246 messages only
|
|
57
|
+
**9,010** were ever sent - and `--days 400` did almost nothing, because fetching the newest 120 messages and then
|
|
58
|
+
filtering by date can only ever pass messages inside the window. That version produced 254 people; the corrected
|
|
59
|
+
one produces 572. Fixing it meant **paging through the whole conversation** (`get_messages` takes an `offset`),
|
|
60
|
+
against the measured ceiling: the user's requirement was that **one group chat stays in one context** (splitting
|
|
61
|
+
would hide cause and effect), so the question was whether it fits - and the API's own error answered it:
|
|
62
|
+
`maximum context length is 1048576 tokens. However, you requested 1052622`. Two fixes then: the per-card output
|
|
63
|
+
cap had to go from 2,000 to **16,000** tokens (34 conversations were failing with `finish_reason=length`, JSON
|
|
64
|
+
truncated mid-object, reported only as "the model did not return usable JSON") and the prompt is trimmed from the
|
|
65
|
+
**oldest** end against a character budget when it would not fit. The second run: **132/132, 0 failures.**
|
|
66
|
+
|
|
67
|
+
- **`[[name]]` now carries which section it came from, because dropping it made the model overwrite the material
|
|
68
|
+
with its own prior.** A chat card separates `### 话题` from `### 人`, and aggregation used to treat every link
|
|
69
|
+
identically. Measured consequence: a contact whose nickname is **白马非马** got a page about 公孙龙's classical
|
|
70
|
+
paradox - and the material handed to the model said, verbatim, "对话对方,准备面试和申请博士,学生证在我这里".
|
|
71
|
+
The refs now carry `人物` / `话题` into the reference line (`- [白马非马](人物 · 白马非马 · 聊天):…`) and the
|
|
72
|
+
prompt says explicitly that a `人物` row means a **person** from the user's chats and must not be written as a
|
|
73
|
+
same-named thing, work or allusion. Rebuilt, that page now reads "白马非马是用户聊天记录中的一位联系人,正处
|
|
74
|
+
最后一学年,准备面试和申请博士", tagged 联系人 / 求职升学 / 私人对话. "Not annotated" and "annotated as a topic"
|
|
75
|
+
stay distinct - an article note has neither section, so it gets neither label.
|
|
76
|
+
|
|
77
|
+
This is a **contract change**: `scan_articles`' `wikilinks` went from `(name, desc)` to `(name, desc, kind)`.
|
|
78
|
+
The four downstream tests that read it as pairs were updated to the new shape rather than loosened.
|
|
79
|
+
|
|
80
|
+
- **`search_knowledge` now returns a concept's neighbours, so the assistant can walk the graph.** The Obsidian
|
|
81
|
+
graph view is a human artefact - nothing on the assistant's side ever read "the graph", and the tool returned a
|
|
82
|
+
single page in isolation. But a concept page already lists its neighbours under `## 相关概念`, and **measured,
|
|
83
|
+
every page is shorter than the tool's 2,000-character slice** (median 730), so that list was already inside the
|
|
84
|
+
returned text - it was simply never pointed at, and the neighbour definitions were never resolved. The tool now
|
|
85
|
+
appends each neighbour that has a page **with its one-line definition**, plus a count of the ones that do not
|
|
86
|
+
(so the model does not chase a name it cannot fetch), and says in its own description that it can be called again
|
|
87
|
+
for any neighbour. One lookup becomes "this concept plus a small subgraph of what it connects to": verified
|
|
88
|
+
against the real vault - `提示工程` returns 上下文学习 / 思维链 / 工具调用 / 智能体, `Claude Code` returns
|
|
89
|
+
Codex / Vibe Coding / 生物信息分析, each with a definition.
|
|
90
|
+
|
|
91
|
+
Two details are pinned by test. **Neighbour names are sanitised before being joined into a path** - they come out
|
|
92
|
+
of file *content*, so `../../etc/passwd` has to stay a filename (same rule as `compile_wiki`; a mutation check
|
|
93
|
+
removing the sanitiser fails the traversal test). And the "no page" case reports a count rather than a name,
|
|
94
|
+
because a name the model cannot fetch is worse than no name.
|
|
95
|
+
|
|
96
|
+
- **`backfill_articles --vault-copy / --vault-sync`: the raw articles reach the Vault's `Sources/` without their
|
|
97
|
+
images.** The daily pipeline copies a whole day into `Sources/WeChat/<date>/` with `copytree`, images included -
|
|
98
|
+
~200 MB per day, which is why the historical import deliberately skipped it (165 days would have been ~33 GB).
|
|
99
|
+
But skipping it left the Vault **inconsistent in a way only the user could see**: `002_Literature/` held the
|
|
100
|
+
reading notes for all 175 days while `Sources/` held raw material for eleven, so the raw-material layer looked
|
|
101
|
+
stale and nothing in the vault said why. `copy_to_vault()` copies the `.md` files only (~14 KB per article,
|
|
102
|
+
~24,500 files, ~400 MB for the 165 days) and copies them **file by file rather than replacing the directory** -
|
|
103
|
+
`copytree`'s semantics would have removed whatever else was in that day's folder, and this is the user's Vault.
|
|
104
|
+
`--vault-sync` runs the copy alone, without fetching and without calling a model, because the days were already
|
|
105
|
+
on disk from the earlier import; that is how the gap was closed.
|
|
106
|
+
|
|
107
|
+
The honest note on this one: I had flagged the omission **in conversation only**, and a caveat that lives only in
|
|
108
|
+
chat is not a caveat. Nothing in the vault or the docs recorded that `Sources/` was deliberately incomplete.
|
|
109
|
+
|
|
110
|
+
- **The right-click menu gained a second section: eight one-click functions.** The first section is still the
|
|
111
|
+
`quickReplyContacts` list (draft a reply for that person); below it now sit 谁在等我回话 / 我的待办 / 今日日报 /
|
|
112
|
+
最近统计 / 阅读统计 / 最近会话 / 朋友圈 / 微信读书. The admission rule for that section is one thing only:
|
|
113
|
+
**the action has to be completable by one fixed sentence**, because a native menu cannot collect input. Anything
|
|
114
|
+
needing a query, a target, or a path (search, export, read-one-message) is therefore excluded rather than shipped
|
|
115
|
+
as an item that does nothing when clicked. All eight map to tools the assistant already has, and each prompt names
|
|
116
|
+
its tool (`用 get_todos …`) - that is a **request to the model, not a hard binding**: the assistant still picks,
|
|
117
|
+
from 19 tools, and a wrong pick is possible; adding a parameter to force it is a separate decision for when that
|
|
118
|
+
actually happens.
|
|
119
|
+
|
|
120
|
+
Three design points, each with a failure it prevents. **The prompt travels with the menu item** rather than the
|
|
121
|
+
page keeping its own id→prompt table: two tables drift, and the drift shows up as "the menu says A, clicking does
|
|
122
|
+
B", silently. A test asserts `renderer.js` contains no action id at all. **The cost line is derived from the
|
|
123
|
+
actions** (`costNote()`), not written down: the hand-written version goes stale the moment a function is added,
|
|
124
|
+
and a stale cost warning is worse than none - it makes people think they were told. It now names exactly which
|
|
125
|
+
items call a cloud model (起草回复 and 谁在等我回话) and says the rest are local reads, so the free ones are
|
|
126
|
+
visibly free. **An empty contact list no longer produces a nearly-empty menu**: the functions and the close item
|
|
127
|
+
are always there, so a user who never configured `quickReplyContacts` still gets a usable menu.
|
|
128
|
+
|
|
129
|
+
The pick payload changed shape (`{kind:'contact'|'action', …}` instead of a bare name), and the page ignores
|
|
130
|
+
anything that is not that shape - during an upgrade the menu and the page are two pieces of code in two processes,
|
|
131
|
+
and a bare name from the old contract would otherwise be dispatched as a *draft nobody asked for*.
|
|
132
|
+
|
|
133
|
+
- **`article-notes --topic` - and the number that makes it worth having.** Concept extraction is the only step in
|
|
134
|
+
this pipeline that costs money per article, so the useful question is not "does it support filtering" but "how
|
|
135
|
+
much does filtering save". Measured rather than estimated, on the 3,498 backfilled articles: news is **57%**, AI
|
|
136
|
+
28%, academia 13%. Carding only AI therefore removes more than half the spend. The filter is applied **before**
|
|
137
|
+
`--limit`, so `--limit 500 --topic AI` means "500 AI articles", not "the newest 500, of which keep the AI ones" -
|
|
138
|
+
the latter silently under-delivers as you page backwards and looks like the corpus is that small. Both spellings
|
|
139
|
+
of the topic field are accepted because the Vault's existing 1,633 notes use `hasTopic: [[AI]]` while newer
|
|
140
|
+
writers use `topic: AI`; the read path is `compile_wiki.article_topic`, unchanged. The confirmation prompt now
|
|
141
|
+
shows the resolved topic list, because the number a person is agreeing to is the filtered one.
|
|
142
|
+
|
|
143
|
+
**The per-call cost was measured against the account balance, not a price table.** DeepSeek's published pricing
|
|
144
|
+
has at least three mutually inconsistent versions in circulation (¥3/6, $0.14/0.28 and $0.22/0.66 per million for
|
|
145
|
+
the same model), so 120 probe calls were run and the balance delta read: **¥0.22 for 120 calls, ¥0.0018 each,
|
|
146
|
+
≈¥30 for 16,600 articles**. The balance endpoint resolves in ¥0.01, so that figure carries a few percent of
|
|
147
|
+
rounding error, and it is the account's real spend rather than a rate card's. It also surfaced something no price
|
|
148
|
+
table could: the balance was ¥27.12, i.e. the unfiltered run would have run out of money partway through. Two
|
|
149
|
+
cheaper-looking knobs were measured and rejected: `MAX_ARTICLE_CHARS` (3000) never binds, because a real call
|
|
150
|
+
sends ~1,394 characters, so lowering it saves nothing; and the input is the larger half (803 of the ~945 tokens
|
|
151
|
+
per call), so trimming what is sent is the only lever that could matter - which is what `--topic` does, by sending
|
|
152
|
+
nothing at all for the articles that are not wanted.
|
|
153
|
+
|
|
154
|
+
### Fixed
|
|
155
|
+
|
|
156
|
+
- **`search_knowledge`'s fallback reported source titles as body text.** When no page *name* matches, the tool
|
|
157
|
+
falls back to "the file contains this word" - and since pages can now list 300+ article titles in their `## 来源`
|
|
158
|
+
section (the 2026-09-27 change), a word appearing in some *source title* was reported as "concept pages whose
|
|
159
|
+
**body** mentions it". Measured with `Grok-4`: 4 pages hit, **3 of them do not contain it anywhere in the body**
|
|
160
|
+
(only `AdsMind` does). The fallback now searches the body only (`pageBody` cuts at `## 来源`), and returns the
|
|
161
|
+
matching **sentence** rather than just the page name - the assistant had been filling the gap itself, answering
|
|
162
|
+
"Grok-4 was one of the models under test" from its own knowledge rather than from the page.
|
|
163
|
+
|
|
164
|
+
**This was caught by a scenario that then had to be deleted, and the deletion is the interesting part.** The
|
|
165
|
+
natural assertion - forbid the false-positive page names - does not hold: those three names appear **inside the
|
|
166
|
+
real page's own body** (its 「相关概念」 section), so a model mentioning them is *right*, and any page-name
|
|
167
|
+
blacklist misfires. The accuracy question therefore moved down a layer: `pageBody` is a pure function, and it is
|
|
168
|
+
pinned in `test/knowledge-neighbors.test.ts` (mutation-checked: returning the full text turns it red). That is
|
|
169
|
+
"put the test where it can hold", not "skipped".
|
|
170
|
+
|
|
171
|
+
Verified end to end: before the fix the answer listed four pages and invented detail; after it, it says there is
|
|
172
|
+
**one** place and quotes the sentence. The eval re-run after the change is **22/22**.
|
|
173
|
+
|
|
174
|
+
- **`--pages-from-cards` checked the wrong name and produced 5 broken links.** The guard that skips names the
|
|
175
|
+
linter cannot resolve (square brackets, or a title ending in `.md`) was applied to the **concept** name but not
|
|
176
|
+
to the **source** name - so `IPCC图件`, an ordinary-looking concept whose single card carries square brackets,
|
|
177
|
+
got a source line pointing at nothing. Caught by running the health check right after, not by reasoning: the
|
|
178
|
+
broken-link count went 0 -> 5. Fixed, the five pages removed, and a test pins it (with the mutation checked:
|
|
179
|
+
dropping the guard turns it red).
|
|
180
|
+
|
|
181
|
+
- **Every embedded Dataview query in the Vault was broken, for two independent reasons.** The reading-note and
|
|
182
|
+
daily-note templates embed `dataview` code blocks - **25,676 reading notes and 176 daily notes, 100% of both** -
|
|
183
|
+
and a user checking their vault found them rendering as raw code. Two separate faults, either of which alone
|
|
184
|
+
would have broken the feature:
|
|
185
|
+
|
|
186
|
+
- **The plugin was not installed.** `.obsidian/plugins/` did not exist and `community-plugins.json` was absent,
|
|
187
|
+
while five places in the code write `dataview` blocks and one CLI message tells the user to use "the Obsidian
|
|
188
|
+
Dataview plugin". Dataview 0.5.68 is now installed and enabled in this vault (it is a community plugin, so it
|
|
189
|
+
is per-machine and **not** covered by the repo - `output/` is gitignored, and the 2.4 MB `main.js` is
|
|
190
|
+
deliberately not committed).
|
|
191
|
+
- **The queries themselves were wrong**, which is the part that would have survived installing the plugin.
|
|
192
|
+
`SORT date DESC` sorts by a field **that does not exist** - the reading notes carry `published`, not `date`.
|
|
193
|
+
The daily note used `WHERE created = date(...)`, and `created` is the **generation** date: one backfill wrote
|
|
194
|
+
`created: 2026-09-26` into all 25,676 notes (measured), so 175 of the 176 daily notes listed nothing and the
|
|
195
|
+
one for the generation date would have listed twenty-five thousand. The related-articles table also listed the
|
|
196
|
+
note itself, and led with `rating` - a field that is empty across the whole corpus because it is the user's to
|
|
197
|
+
fill, so ten rows of blanks.
|
|
198
|
+
|
|
199
|
+
Both templates now use `published`, exclude `this.file`, and lead with `published`/`source`. **Fixing the
|
|
200
|
+
templates alone would have fixed nothing**: `create_reading_note()` skips a note that already exists, so the
|
|
201
|
+
25,852 existing files needed a rewrite pass - `create_reading_notes --refresh-queries` rewrites only the code
|
|
202
|
+
block, is idempotent, leaves notes without one alone (counted, not silently skipped), and reports the notes whose
|
|
203
|
+
topic is empty separately rather than inventing one. Applied: 25,845 + 7 rewritten, 0 stale queries left.
|
|
204
|
+
|
|
205
|
+
- **`vault_search --type note` searched a directory that has never existed in this repo.** It read `Vault/Notes/`,
|
|
206
|
+
while the notes live in `002_Literature` (25,676 reading notes), `001_Daily`, `003_Ideas` and `008_MOC`. The
|
|
207
|
+
failure mode is the quiet one: the directory simply does not exist, so the type returned nothing and the command
|
|
208
|
+
still reported "found N results" - **the largest layer of the vault was invisible to search**, which is the layer
|
|
209
|
+
an agent would most want. Now it reads the four real directories (4.6s for a query over 26k notes). Pinned by
|
|
210
|
+
`test/vault_search_test.py`, including a regression test for the empty result, verified by a mutation check that
|
|
211
|
+
puts `Notes/` back.
|
|
212
|
+
|
|
213
|
+
The article half of the same function keeps its `--days 90` default, which **now hides most of the corpus**: the
|
|
214
|
+
vault spans 175 days (March-September), so anything older than ~90 days is unreachable unless `--days` is raised.
|
|
215
|
+
Reported, not changed - it is a default, and changing it changes how long every search takes.
|
|
216
|
+
|
|
217
|
+
- **The graph view drew 10,466 ghost nodes.** `.obsidian/graph.json` had `hideUnresolved: false`, so the concept
|
|
218
|
+
graph (`search: path:"Wiki/Concepts"`) rendered not only the 1,963 concept pages but every link target that has no
|
|
219
|
+
page - 10,466 of them, against 2,794 real concept-to-concept edges. Verified by screenshot after the change: the
|
|
220
|
+
graph now shows only real concepts (`Github Copilot`, `GPT-6`, `Claude Code Skills`, `MCP 权限边界`, …). It is
|
|
221
|
+
also **sparse** - 1.4 real edges per page, 498 pages with no concept-to-concept link at all - because most of each
|
|
222
|
+
page's "related concepts" are concepts that have no page yet. Building the remaining 3,725 candidate concepts
|
|
223
|
+
would densify it; that is a spend decision, not a defect.
|
|
224
|
+
|
|
225
|
+
- **The generated MOCs and the concept pages' source links had defects that only show up in Obsidian.** The user's
|
|
226
|
+
bar was "the knowledge base should present properly in Obsidian", so this pass looked at what renders rather than
|
|
227
|
+
at what runs:
|
|
228
|
+
|
|
229
|
+
- **`008_MOC` had a file called `MOC-.md`.** The 9 notes with an empty topic produced a malformed filename (and
|
|
230
|
+
the same guard was missing in the ideas generator, which would have written `想法-.md`). Empty-topic notes now
|
|
231
|
+
produce nothing on either path; "no topic" is not a topic.
|
|
232
|
+
- **MOC links were written as `[[<date>/<filename>.md|<title>]]`** while the notes' own links are
|
|
233
|
+
`[[<date>-<title>|<title>]]`. Neither full path nor bare filename - Obsidian resolves by shortest unique path,
|
|
234
|
+
so these were at best accidental. Links are now the file stem, which is what the rest of the vault uses; verified
|
|
235
|
+
against the filesystem: 4,497 links, 0 ambiguous stems, 0 pointing at a file that does not exist.
|
|
236
|
+
- **The MOC's own dataview query carried the same two faults fixed in the reading notes** (`SORT date` on a corpus
|
|
237
|
+
that has `published`; `contains(hasTopic, …)` on a nested list).
|
|
238
|
+
- **Concept pages linked their sources by the *card's* filename, not the reading note's.** Cards are named
|
|
239
|
+
`{date}-{full title}` and reading notes `{date}-{title truncated to 50}`, so for short titles (98%) they coincide
|
|
240
|
+
and the link resolved **by luck**; measured, **125 of 5,470 source links pointed at nothing**. The card already
|
|
241
|
+
records the right name in its `from` field - unused until now. `compile_wiki --fix-source-links` repairs the
|
|
242
|
+
existing pages locally (no model calls, ~20 lines, idempotent, only touches lines with the ` — ` form that the
|
|
243
|
+
source section uses, so the "related concepts" list is unaffected). Applied: **125 -> 17**, and the 17 remaining
|
|
244
|
+
point at the user's own notes, which are deliberately not copied into the Vault.
|
|
245
|
+
- Those same source links also carried a **`.md` extension** - the only place in the whole vault that does. Every
|
|
246
|
+
other link (note-to-note, daily notes, MOCs) omits it. Whether Obsidian tolerates the extension is not something
|
|
247
|
+
I verified, so the code now emits the form that is known to work and is used everywhere else.
|
|
248
|
+
|
|
249
|
+
- **Three more Vault directories were declared but never written to, and the existing Vault had never been
|
|
250
|
+
initialized at all.** Auditing the layout after the `007_Wiki` bug found the same shape twice more: `Wiki/Entities/`
|
|
251
|
+
and `Wiki/Topics/` are created by `vault init` **and described in the generated `README.md` as if they existed**
|
|
252
|
+
("实体页(公众号、作者等)", "主题总览页"), while nothing in the codebase writes either; and `.obsidian/app.json`
|
|
253
|
+
set `attachmentFolderPath: 'Assets'` while the layout creates `_attachments` - so attachments would have gone to
|
|
254
|
+
a folder that is never created. That `Assets` literal appeared exactly **once** in the whole codebase, which is
|
|
255
|
+
the same tell as before: a name that exists in one place and contradicts the two places that matter. Both are
|
|
256
|
+
fixed, and the generated README's directory table now describes the real layout - including an explicit note that
|
|
257
|
+
the `000`-`008` series is for the user's own notes and stays empty until `vault promote`, because "empty by design"
|
|
258
|
+
and "empty because nobody built it" are indistinguishable to whoever is reading the vault.
|
|
259
|
+
|
|
260
|
+
The two layout lists live in different languages (the TS CLI and a Python script) so they cannot share a
|
|
261
|
+
constant; the agreement is pinned by test instead - the pattern this repo already uses for its four copies of
|
|
262
|
+
`TOPIC_ORDER`. `test/create_reading_notes_test.py` parses the CLI's `dirs` array and asserts that it creates
|
|
263
|
+
everything `VAULT_DIRS` expects, declares no `Wiki/*` subdirectory besides `Concepts`, and that
|
|
264
|
+
`attachmentFolderPath` names a directory the layout really creates. Three mutation checks.
|
|
265
|
+
|
|
266
|
+
**The Vault itself had never been initialized.** It had been built by `create_reading_notes`'s `mkdir` loop, so
|
|
267
|
+
`README.md`, `Templates/article.md`, `.gitignore` were missing and `.obsidian/app.json` was `{}`. One
|
|
268
|
+
`vault init --yes` fixed it - **with Obsidian closed first**, because Obsidian holds `app.json` in memory and
|
|
269
|
+
writes it back on exit, which would have silently reverted the corrected attachment path.
|
|
270
|
+
|
|
271
|
+
- **The Vault declared its concept directory twice, and one of the two was never written to.** `VAULT_DIRS`
|
|
272
|
+
(created by `vault init` / `create_reading_notes`) listed `007_Wiki/Concepts`, while the pages are actually
|
|
273
|
+
written to the top-level `Wiki/Concepts` - which is also what `vault_rag`, `vault_search`, `wiki_lint`, the
|
|
274
|
+
assistant's knowledge search and two CLI options read: **six places versus two**. So every init created a
|
|
275
|
+
`007_Wiki/` folder that no code ever filled, and a user looking at their vault sees an empty directory in the
|
|
276
|
+
numbered series and concludes the knowledge base was not updated. That is exactly how it was reported. The
|
|
277
|
+
template now declares `Wiki/Concepts` (the real one), the CLI's init preview lists it once instead of twice, and
|
|
278
|
+
the empty directory is gone from the vault. `test/compile_wiki_test.py` pins that whatever the template declares
|
|
279
|
+
for concepts **must equal** where `compile_wiki` actually writes - verified by a mutation check (putting
|
|
280
|
+
`007_Wiki/Concepts` back fails the test).
|
|
281
|
+
|
|
282
|
+
### Changed
|
|
283
|
+
|
|
284
|
+
- **The assistant eval now actually exercises the knowledge base.** Of its 19 scenarios, the only
|
|
285
|
+
knowledge-related one was `which-store-knowledge` - "does it know *which* store to search" - and none made it
|
|
286
|
+
read a page. That was fine when the vault held 5,062 pages; it now holds **22,374**, and the knowledge line is
|
|
287
|
+
the part of the assistant that grew most. Three scenarios added: a concept from the article line, one from the
|
|
288
|
+
chat line (whose answer - "project lead / coordinator / ABaCAS" - is **only** in the page, so the assertion
|
|
289
|
+
cannot be satisfied from general knowledge), and one concept the vault does not contain at all.
|
|
290
|
+
|
|
291
|
+
**The third is the valuable one**: it asks about something with zero matches, and the point is not that it
|
|
292
|
+
answers, but that it **does not invent**. It calls `search_knowledge` twice, finds nothing, and says so -
|
|
293
|
+
asserted with `toolEmpty` rather than "the answer must contain one of these words", because the wording of an
|
|
294
|
+
honest "I don't have that" is unbounded. That reasoning was already written next to `EvalCase`; this is the
|
|
295
|
+
first scenario to use it for its intended purpose.
|
|
296
|
+
|
|
297
|
+
**The eval immediately caught something, and it was my scenario, not the assistant.** The first version asked
|
|
298
|
+
"who is 龙老师?" - and the model reasonably read that as a question about a person *in the chats* and went to
|
|
299
|
+
`search_chats` (it made no claims about the failure, which is the `tool-failure-honesty` behaviour working).
|
|
300
|
+
The question now names the store ("in the concept pages I organised, who is 龙老师?") because that is the path
|
|
301
|
+
the scenario exists to measure. All three pass three runs in a row (9/9) and the full suite is **22/22** - the
|
|
302
|
+
first baseline this line has had.
|
|
303
|
+
|
|
304
|
+
Note these three **depend on this machine's vault** (the pages are real files, not synthesised like the
|
|
305
|
+
sessions and favourites), so they will fail elsewhere or after a rebuild - and that would not be an assistant
|
|
306
|
+
problem.
|
|
307
|
+
|
|
308
|
+
- **The WeChat reader residue in note summaries is cleaned up - 1,484 of them, and the scope mistake is recorded
|
|
309
|
+
too.** The cleaner runs *at read time* (`create_reading_notes` calls `strip_wx_ads` while writing a summary), so
|
|
310
|
+
notes written before that logic existed were never cleaned: 1,484 of 25,676 carried page-button text inside their
|
|
311
|
+
summary (`继续滑动看下一个`, `去阅读`, `轻触阅读原文`). New notes are already clean, so this is a one-off
|
|
312
|
+
backfill: `create_reading_notes --clean-summaries`, local and idempotent. Re-measured afterwards: **0 notes left
|
|
313
|
+
with residue in the summary**.
|
|
314
|
+
|
|
315
|
+
**The first version swept the whole vault** (`Path(vault).rglob('*.md')` instead of the notes layer) and cleaned
|
|
316
|
+
**10,949 raw articles under `Sources/WeChat`** as well - material, which this command has no business touching.
|
|
317
|
+
The damage was checked with a diff rather than assumed: what was removed is the page-button phrases and
|
|
318
|
+
underscore rules, plus 167 lines of whitespace normalisation; **no article content was lost**. `Sources/` has no
|
|
319
|
+
backup, but it can be re-copied from `output/biz-daily`, so it is reversible - and it was left as is, because
|
|
320
|
+
reverting would put the interface noise back while leaving the 4,809 articles that have no summary section
|
|
321
|
+
uncleaned, i.e. a larger inconsistency. The scope is now pinned by a test (the "sweep the whole vault" mutation
|
|
322
|
+
fails).
|
|
323
|
+
|
|
324
|
+
One more criterion worth recording: the check is "this summary section **contains** one of the eight known
|
|
325
|
+
phrases", **not** "the text changed" - `strip_wx_ads` also normalises whitespace and strips, so any paragraph
|
|
326
|
+
with a trailing newline "changes", and using that as the trigger rewrites everything (measured while
|
|
327
|
+
investigating: the criterion was always true and reported all 25,676, against a true answer of 1,484).
|
|
328
|
+
|
|
329
|
+
- **Two more duplicate pairs merged by hand, out of the 741 Jev had proposed.** The loose "similar name" set
|
|
330
|
+
was narrowed by rule to 27, and reading each one's actual definition left exactly **two** that are the same
|
|
331
|
+
thing (`TRAE IDE Linux 版` / `TRAE IDE Linux 版本`; `100 万 token 上下文窗口` / `100万token上下文`). The
|
|
332
|
+
other twenty-five were rejected on meaning rather than on spelling - "日均Token调用量" is a rate where
|
|
333
|
+
"Token调用量" is a total, "2026世界人工智能大会" is one year's event against the series, "Token 节省" is a
|
|
334
|
+
concept where "Token 节省技巧" is a set of methods. Merged with aliases; nothing linked to the dropped
|
|
335
|
+
names, and the health check reports **0 broken links** across 22,374 pages.
|
|
336
|
+
|
|
337
|
+
That is the useful form of this result: a decision model proposed 741 merges, a string rule then cut them to
|
|
338
|
+
27, and a human reading the definitions kept 2. The graph's name-level duplication is now settled.
|
|
339
|
+
- **`wiki lint` is fast again at the new scale - and the "similar names" count it prints is now mostly noise.**
|
|
340
|
+
The near-duplicate check compared every page with every other (5,062 pages took 1-2 minutes; 22,445 is twenty
|
|
341
|
+
times the pairs). It now builds a 5-gram inverted index and compares only within a bucket, which is **the same
|
|
342
|
+
result by construction**: the criterion is "longest common substring >= 5 characters", and that is equivalent
|
|
343
|
+
to "shares a 5-character substring", so a pair that shares none cannot pass. Verified two ways - brute-force
|
|
344
|
+
comparison on random data (2,258 groups either way, identical as sets) and the real vault: **22,381 pages in
|
|
345
|
+
4.2 seconds**.
|
|
346
|
+
|
|
347
|
+
The fix also made something visible that the slowness had been hiding: at this size the check reports
|
|
348
|
+
**425,187 pairs**, up from 13,367. Pages grew 4.4x, pairs grow quadratically, and the new pages are
|
|
349
|
+
single-article fragments whose names overlap heavily (`Agent…`, `Token…`). That number is no longer a signal -
|
|
350
|
+
the one time it was investigated properly, roughly three pairs out of 741 candidates were genuinely the same.
|
|
351
|
+
The report still prints only the first ten, so it stays readable. Loosening or tightening the criterion would be
|
|
352
|
+
a change of meaning, and there is no evidence yet for what it should become, so it is recorded rather than
|
|
353
|
+
adjusted.
|
|
354
|
+
|
|
355
|
+
- **17,383 pages built without calling a model** - `wiki compile --pages-from-cards`. The candidate backlog was
|
|
356
|
+
priced at about **¥31** and the user's verdict was "that's a bit expensive", which is right: every candidate is
|
|
357
|
+
mentioned by exactly **one** article, so there is nothing to synthesise - a model would only be rewording a
|
|
358
|
+
single sentence. The definition is taken from the `desc` the card already carries, related concepts come from
|
|
359
|
+
**co-occurrence** (the other concepts in the same article, median 3), and the source is that article.
|
|
360
|
+
|
|
361
|
+
**The honest description is "reusing output already paid for", not "no model involved".** Those `desc` lines
|
|
362
|
+
were written by `article_notes` when it extracted concepts, so the quality is that line's quality: most read
|
|
363
|
+
like definitions (`招聘陷阱` - "job seekers should be wary of high-salary bait"), a few are meta-descriptions
|
|
364
|
+
(`免疫生态位` - "the other thing the article title splits out alongside cell types"). Pages carry
|
|
365
|
+
`summary_by: card` to say where the text came from, following the `summary_by: model` convention that
|
|
366
|
+
`user_notes` established.
|
|
367
|
+
|
|
368
|
+
Verified: **0 broken links** among the 17,319 new pages' source lines, only 40 ended up with an empty
|
|
369
|
+
"related concepts" section, and a re-run builds 0. Two of the four mutations that had to be caught were about
|
|
370
|
+
coverage, not correctness - and one guard was added after the first dry-run showed it building **18,885** pages
|
|
371
|
+
across every card directory at once, which would have put chat-line concepts into `Wiki/Concepts`. The flag now
|
|
372
|
+
requires explicit `--cards` and prints the exact commands when it is missing.
|
|
373
|
+
|
|
374
|
+
- **A scale problem the new pages exposed: `wiki lint` no longer finishes in reasonable time.** Its
|
|
375
|
+
near-duplicate check compares every page with every other (5,062 pages took 1-2 minutes; 22,445 is **20x** the
|
|
376
|
+
pairs). The check is the one that produced the 13,367 "similar names" and, through that, the 74 duplicate
|
|
377
|
+
groups worth merging - so it cannot simply be dropped. It needs bucketing (compare only within a name-length
|
|
378
|
+
band or a normalised prefix) rather than all-pairs. Not fixed here; recorded so the next person does not
|
|
379
|
+
discover it by waiting.
|
|
380
|
+
|
|
381
|
+
- **One concept, one node: 74 groups of duplicate pages merged** - `wiki compile --merge-duplicates`, local.
|
|
382
|
+
The user's read was that the graph was piling up rather than getting better: "some of them are the same thing
|
|
383
|
+
but drawn as different ones". Measured: 74 groups, 80 redundant pages, where the same concept had two or three
|
|
384
|
+
nodes because of a hyphen, a space, or a plural (`GPT 5.6` / `GPT-5.6` / `GPT5.6`, `AI skill` / `AI skills`,
|
|
385
|
+
`AI 工具` / `AI工具`).
|
|
386
|
+
|
|
387
|
+
**The trick that makes it cheap is `aliases`.** The group keeps the page with the most sources (`claude code`
|
|
388
|
+
has 411, `ClaudeCode` has 7), the other names go into that page's `aliases`, their source lines are merged in,
|
|
389
|
+
and the extras are deleted. **98 links pointed at the names that were deleted and every one of them still
|
|
390
|
+
resolves** - because Obsidian honours aliases, nothing else in the vault had to change. Verified by listing
|
|
391
|
+
all 80 dropped names and checking each is an alias on some page: 80/80, and 98/98 links covered.
|
|
392
|
+
|
|
393
|
+
The criterion is a normal form (strip spaces/hyphens/punctuation, lowercase, drop an English plural tail), not
|
|
394
|
+
the lint's "5-character common substring" rule - that one calls `Claude Code` and `Claude 4.8` a pair, and the
|
|
395
|
+
merge deliberately does not. The lint now reads `aliases` too, so those names stop being reported as "not built
|
|
396
|
+
yet, run compile with a higher limit" (that count fell 9,421 -> 9,384).
|
|
397
|
+
|
|
398
|
+
- **500 pages built from the 17,774-candidate backlog, as a trial.** The candidates are all "mentioned by exactly
|
|
399
|
+
one article", which is why the earlier `--min-refs`/`--limit` filters had left them alone. Sampling them: the
|
|
400
|
+
definitions are specific and readable (`TRAE SOLO 模式` - "AI-driven development mode that plans and runs the
|
|
401
|
+
whole flow from requirements to preview"), each links 3-4 related concepts, and each has exactly one source.
|
|
402
|
+
The other 17,274 were left alone pending a look.
|
|
403
|
+
|
|
404
|
+
With them came a fix that was written down but not implemented: `generate_concept` truncated the page's source
|
|
405
|
+
list to five (`for r in refs[:5]`), which is the *prompt* budget's job (`build_ref_lines`), not the page's. A
|
|
406
|
+
page that lists 5 sources while the index says 301 is exactly the gap `--refresh-sources` was written to close,
|
|
407
|
+
so new pages would have been born needing it.
|
|
408
|
+
|
|
409
|
+
- **Old concept pages can grow new edges now** - `wiki compile --refresh-sources`, local, no model calls.
|
|
410
|
+
The gap it closes was measured: `wiki compile` only creates pages for concepts that do not have one yet, so
|
|
411
|
+
an existing page is never touched - a new article that mentions `DeepSeek` does not add a line to that page.
|
|
412
|
+
Meanwhile `OPERATIONS.md` claimed the concept pages were "a ledger". One run of the new flag rewrote the
|
|
413
|
+
source section of **324 article-line pages (+3,164 edges)** and **34 chat-line pages (+148)**.
|
|
414
|
+
|
|
415
|
+
Three things it deliberately does not do, each because the obvious version is silently wrong:
|
|
416
|
+
|
|
417
|
+
- **It does not touch the frontmatter.** The body and `sources:` carry *deliberately different* names: the
|
|
418
|
+
body needs what Obsidian can resolve (chat cards are `会话-<name>`, pointing at `Sources/Chat/`), while
|
|
419
|
+
`sources:` needs what `source_kinds_for` can find under `output/<line>/<card>.md` (chat cards there have
|
|
420
|
+
**no** prefix). Syncing either side breaks `来源/聊天` tag inference - and since tags only ever grow, the
|
|
421
|
+
page still *looks* right afterwards.
|
|
422
|
+
- **It only adds.** Source lines that no current card accounts for stay exactly as they are (322 of them on
|
|
423
|
+
the article line); they are counted, not deleted. So a page can legitimately hold more sources than the
|
|
424
|
+
index reports - the index says what the cards say *now*, the page holds every reference it ever had.
|
|
425
|
+
- **It skips names that cannot be written.** Six with square brackets (Obsidian itself cannot resolve them)
|
|
426
|
+
and one whose *title* ends in `.md` - that last one would have turned the lint's "0 broken links" into 1,
|
|
427
|
+
because `wiki_lint.resolve` treats a `.md`-suffixed name as a complete filename while the real card is
|
|
428
|
+
`….md.md`.
|
|
429
|
+
|
|
430
|
+
Ordering is normalised with `sorted()` (a spec, not a history - measured: that costs 29 extra pages). Two
|
|
431
|
+
mistakes were made and caught here rather than reasoned away: a lenient reader that was off by one character
|
|
432
|
+
(`line[3:]` instead of `line[4:]`) manufactured **6,721 phantom missing edges** and would have driven the
|
|
433
|
+
whole change, and the `.md`-suffix case above put a real broken link into `Codex.md`. The first was found by
|
|
434
|
+
printing one concrete page when the numbers looked wrong; the second by running the lint afterwards.
|
|
435
|
+
|
|
436
|
+
- **The three real states each have a face now.** The panel has always tracked `busy` / `offline` / `quota`
|
|
437
|
+
(that is what the coloured halo reads), so this adds three faces to classes that already existed - **no new
|
|
438
|
+
state machine**. Busy is a calm half-closed eye with a small mouth (focused), offline is a closed sad eye
|
|
439
|
+
with a squiggly mouth (apologetic), quota-out is a heavy-lidded eye with an oval sigh (tired). The halo stays:
|
|
440
|
+
it is the ambient light you catch peripherally, the face is the detail you only see by looking, and they say
|
|
441
|
+
the same thing at two distances rather than repeating each other.
|
|
442
|
+
|
|
443
|
+
**The order of two CSS rules is a contract here.** `ball-happy` must come *after* the three state rules,
|
|
444
|
+
because with equal specificity the later rule wins - otherwise poking the ball while the assistant happens to
|
|
445
|
+
be busy shows the focused face and the poke does nothing visible. That is asserted, not commented.
|
|
446
|
+
|
|
447
|
+
All three are generated through the same `/images/edits` + mask pipeline as the grin, with the mask widened to
|
|
448
|
+
a third hole over the mouth (a resigned face cannot keep grinning). Each was checked for the thing that
|
|
449
|
+
actually breaks a circular ball: **0 solid pixels outside the inscribed circle**. The busy face took two tries
|
|
450
|
+
- the first came back with angled slits that read as *suspicious* rather than *concentrated*.
|
|
451
|
+
|
|
452
|
+
- **Poking the ball makes it grin.** Press it and it switches to a second face - the same cat with its eyes
|
|
453
|
+
closed in a happy arc - and it goes back to the normal face the instant you let go. The first version held
|
|
454
|
+
the grin for 900ms after release, on the theory that a click lasts a few tens of milliseconds and an
|
|
455
|
+
expression that short is one nobody sees; **the user tried it and said the delay felt stuck**, so there is
|
|
456
|
+
no hold at all now, and a test asserts the constant is gone rather than weakened. `pointercancel` also
|
|
457
|
+
reverts, so a pointer stolen by the system cannot leave the ball grinning forever.
|
|
458
|
+
|
|
459
|
+
The second face is a **generated** image, and both halves of that needed measuring. It was produced with the
|
|
460
|
+
same endpoint's `/images/edits` (image-in, not text-in - `/images/generations` would have drawn a *similar
|
|
461
|
+
cat* rather than *this* cat) with a mask whose transparent area was the two eyes and the eyelids. **The model
|
|
462
|
+
still repainted the whole canvas**: mean colour distance inside the hole 197.8, outside 10.9 - so the shipped
|
|
463
|
+
file takes the generated pixels **only inside the hole**, feathered at the edge, and keeps the original
|
|
464
|
+
everywhere else, which is also why switching faces cannot shift the colours of the face. It came back **RGB,
|
|
465
|
+
with the transparent background filled black** (the second time this endpoint has done that), so the alpha
|
|
466
|
+
channel is taken from the source art.
|
|
467
|
+
|
|
468
|
+
Both faces are cut and scaled from the same source with the same parameters, so they are pixel-aligned: the
|
|
469
|
+
two 256px assets differ by 3.5% of their pixels, all of them in the eye strip. Switching is a hard cut, not a
|
|
470
|
+
fade - an expression change is one frame long, and a fade reads as an image loading.
|
|
471
|
+
|
|
472
|
+
One gap this closed rather than stepped around: `resources/panel`'s file list was hand-written and nothing
|
|
473
|
+
checked it, so **a new asset could be added without a single test going red** (measured: deleting
|
|
474
|
+
`mascot-happy.png` from the list kept the whole file green). The list is now a constant, and a test
|
|
475
|
+
reconciles it against the actual directory in both directions.
|
|
476
|
+
|
|
477
|
+
- **The ball is a quarter bigger and answers the mouse.** `BALL_SIZE` 76 -> 96 (the window grows with it;
|
|
478
|
+
the expanded window is now 528 wide instead of 508), and the artwork is re-derived with a 2% margin instead
|
|
479
|
+
of 4%, so the cat itself is ~28% larger than before.
|
|
480
|
+
|
|
481
|
+
The interactions existed but were too quiet to notice: hover only changed the shadow and brightness, and
|
|
482
|
+
pressing gave `scale(.95)` with an `ease` transition - a 5% squeeze that snaps back rather than bounces.
|
|
483
|
+
Now hover scales to 1.02, press squashes to 0.92, and the transition is `cubic-bezier(.34, 1.56, .64, 1)` so
|
|
484
|
+
releasing overshoots back. **The enlarged-on-hover factor is 1.02 and cannot be raised on its own**: it is
|
|
485
|
+
paired with the margin the asset is generated with (measured: at 1.02x, one solid pixel falls outside the
|
|
486
|
+
circle; at 1.03x, ten - the window clips the ball to a circle and would shave its edge). A test pins both
|
|
487
|
+
halves. Idle stays perfectly still, so "not moving" still means idle and the halo keeps being the only
|
|
488
|
+
status light - and `prefers-reduced-motion` now stops the **deformation** too, not just the keyframes:
|
|
489
|
+
disabling the transition alone made hover/press jump instantly to another shape, which reads as a glitch.
|
|
490
|
+
|
|
491
|
+
- **The ball wears the mascot's illustrated version; the pixel version is gone.** Same character, drawn
|
|
492
|
+
rather than pixel-art. The new source is 1254x1254 with its content filling 98% of the canvas, which is the
|
|
493
|
+
part that needed care: the ball clips its image to a **circle** (`border-radius: 50%` on both `#ball` and the
|
|
494
|
+
face layer), so pasting it in as-is put the **green speech bubble entirely outside the circle** (10,368 solid
|
|
495
|
+
pixels) along with the paws. The shipped asset is therefore cropped to the alpha bounding box and scaled so
|
|
496
|
+
that the furthest solid pixel sits within 96% of the radius - 1226x1247 becomes 210x213 centred on a 256
|
|
497
|
+
canvas - which measures **0 solid pixels outside the circle**, at 41KB (the source is 813KB). For scale: the
|
|
498
|
+
previous asset filled 80% x 91% of its canvas, the new one 82% x 83%, so the character does not shrink.
|
|
499
|
+
`serveStatic` reads the file per request with `Cache-Control: no-store`, so a running panel only needs a
|
|
500
|
+
**page reload** - no restart. The tray icon (`tray.png`) is a separate image and still shows the old
|
|
501
|
+
pixel-art mascot on its disc.
|
|
502
|
+
|
|
503
|
+
- **The two knowledge bases now live in one Vault, in two directories.** The chat line had grown to 1,519
|
|
504
|
+
concept pages inside `Wiki/Concepts` alongside the article line's 2,115 - one graph, correctly, but the user
|
|
505
|
+
asked for the two to be kept apart. `Chat/` now holds the chat side (`Chat/Concepts/`, `Chat/00-Overview.md`),
|
|
506
|
+
`Wiki/` holds the article side, and no page mixes sources. `Sources/Chat/` (the conversation cards) sits next to
|
|
507
|
+
`Sources/WeChat/` by the same rule.
|
|
508
|
+
|
|
509
|
+
**The half that matters is not the move, it is that every reader had to learn about the second directory.**
|
|
510
|
+
Five places read concept pages - `vault_search`, `vault_rag`, the assistant's `search_knowledge` and
|
|
511
|
+
`conceptNeighbors`, and the MCP `wechat.get_concept` - and each of them reading one directory would have been
|
|
512
|
+
**silently wrong rather than broken**: half the hits missing, no error, the tool still looking like it works.
|
|
513
|
+
They now all read both, the CLI's `vault init` preview creates both, and the generated `README.md` describes
|
|
514
|
+
both. The list itself cannot be shared across the Python/TypeScript boundary (and the MCP package is separate
|
|
515
|
+
again), so `test/concept-dirs-agreement.test.ts` pins all five declarations to one; the two scripts that
|
|
516
|
+
`import` it instead are checked for still iterating it.
|
|
517
|
+
|
|
518
|
+
**A name still belongs to one page.** Splitting the directories immediately created 11 concepts that had a page
|
|
519
|
+
on *both* sides (`DeepSeek`, `智谱`, `Agent Skills` …) - and two files with the same stem make `[[DeepSeek]]`
|
|
520
|
+
ambiguous in Obsidian, which is the same failure the `会话-` prefix prevents for conversation cards. The
|
|
521
|
+
second line now skips a name the first already has, and says so ("跳过 1530 个已有概念(其中 11 个在另一条线
|
|
522
|
+
已有同名页)") instead of folding it into the re-run count. `wiki lint` reports **0** duplicate titles; those
|
|
523
|
+
11 concepts live on the article side and the chat pages link to them, so the overlap still shows up as edges.
|
|
524
|
+
|
|
525
|
+
Three things broke while doing it and were caught by running the suites rather than by reasoning:
|
|
526
|
+
`conceptNeighbors` took a directory and began taking a list, and a caller still passing a string made
|
|
527
|
+
`for (const dir of wikiDirs)` walk the path **one character at a time** and return "no neighbours" - a wrong
|
|
528
|
+
answer, not a crash, so it now throws on a string; and `wiki lint` went from 7,872 broken links to **0** once it
|
|
529
|
+
was told that a bare `[[name]]` may resolve to a card or a raw source as well as to a page.
|
|
530
|
+
|
|
531
|
+
- **`compile_wiki` generates concept pages concurrently too, and filters existing pages before spending.** Same
|
|
532
|
+
pattern as `article-notes` and for the same reason: 1,893 pages took minutes at 8 workers instead of the ~53
|
|
533
|
+
minutes the serial loop would have needed (it deliberately slept 0.5s between calls - a per-article throttle that
|
|
534
|
+
only makes sense when one request is in flight, so the concurrent path does not sleep). Writing stays serial and
|
|
535
|
+
**streams**: a page is written as soon as its answer arrives, so a killed run keeps what it already produced.
|
|
536
|
+
|
|
537
|
+
One trap belongs to this step specifically, and it is a money trap rather than a correctness one: the existing
|
|
538
|
+
pages were previously skipped inside the serial loop (`if out_file.exists(): continue`), which is safe by
|
|
539
|
+
construction. Handing that check to the thread pool - submit everything, discard the results you did not want -
|
|
540
|
+
would **pay for a page that already exists** and show up only on the bill. `build_jobs()` therefore filters
|
|
541
|
+
before submission, and the test asserts the skipped concept's name never reaches `generate_concept`.
|
|
542
|
+
|
|
543
|
+
- **`article-notes` asks concurrently, which is 6.5x faster for the same money.** The loop was one blocking call
|
|
544
|
+
per article; measured, that is 49 cards/minute against **319 cards/minute at 8 workers** - and the token count is
|
|
545
|
+
unchanged, so the bill is identical (~¥11.5 for 6,409 articles). Only the *asking* is parallel: the writing stays
|
|
546
|
+
serial, because a call is ~1.2s (pure network wait) while writing a card is sub-millisecond, so concurrency there
|
|
547
|
+
buys nothing and only makes "who wrote which card" harder to trace. The default is 6, matching `biz_daily`'s
|
|
548
|
+
`JEV_WORKERS` / `SUMMARY_WORKERS` / `IMAGE_WORKERS`, which measure the same upstream.
|
|
549
|
+
|
|
550
|
+
**Why not batch several articles into one call** - it is the other obvious saving, and it was measured too: the
|
|
551
|
+
fixed instructions are 459 characters (32% of a 1,394-character prompt), so 5-per-call cuts prompt tokens to 74%
|
|
552
|
+
and would save roughly ¥2.3. It was **rejected on two grounds**. First, batching does not save *time*: latency is
|
|
553
|
+
dominated by output length, so five times fewer calls each four times slower is a wash - concurrency is the lever,
|
|
554
|
+
and the two are orthogonal anyway. Second, and the reason that matters: `compile_wiki.build_ref_lines` feeds the
|
|
555
|
+
concept-page model up to five references collected **from different articles**, each being that article's `desc`
|
|
556
|
+
line - so cross-article synthesis already happens, downstream and deterministically, and the `desc` line is its raw
|
|
557
|
+
material. A `desc` means "what **this** article said about it"; five articles in one prompt makes "this article"
|
|
558
|
+
ambiguous and blurs exactly the input the fusion depends on. **Not tested**: whether batched extraction yields
|
|
559
|
+
*better* concepts. That needs human labels, and this project does not record calibration conclusions without a
|
|
560
|
+
gold standard - what could be measured is how much the concept sets differ, which is consistency, not quality.
|
|
561
|
+
|
|
562
|
+
- **The floating ball is just the cat while idle.** The coloured halo behind it (`#ball .glow`) used to be painted
|
|
563
|
+
at all times, which was the point when it was added - the user asked for a background that moves. Two costs only
|
|
564
|
+
became visible after living with it: the ball always had a coloured disc behind it, so **"this image has a
|
|
565
|
+
transparent background" was impossible to see**; and that same layer doubles as the status light (`offline` turns
|
|
566
|
+
it red, `quota` amber, `busy` speeds it up and makes it breathe), so having it always on meant having no status
|
|
567
|
+
light - offline, busy and quota-out all looked identical. Now the glow is painted **only** under
|
|
568
|
+
`body.busy` / `body.offline` / `body.quota`: still = the cat, lit = something to say. This is the earlier request
|
|
569
|
+
**narrowed, not cancelled** - the hue drift is still there, it just happens while busy rather than always.
|
|
570
|
+
`test/panel-packaging.test.ts` was rewritten to pin the new contract rather than loosened: idle must be
|
|
571
|
+
`background: none`, and each of the three states must appear **in the selector of the rule that carries the
|
|
572
|
+
radial-gradient** - the first version searched the whole stylesheet for `body.quota #ball .glow`, which also
|
|
573
|
+
matches that state's unrelated `animation: none` rule, so deleting quota from the gradient selector passed. A
|
|
574
|
+
mutation check confirmed both halves of the new assertion fail when broken.
|
|
575
|
+
|
|
576
|
+
- **`scripts/backfill_articles.py` - pulling months of articles into a knowledge base, without the 250-hour path.**
|
|
577
|
+
The existing `pipeline run --date` cannot do this, for three measured reasons rather than guessed ones: the CLI
|
|
578
|
+
wraps the whole pipeline in a hardcoded 10-minute timeout (`bin/weflow-cli.ts:3906`) that one historical day
|
|
579
|
+
already exceeds, so the run dies with zero output; `biz_daily` fetches serially *and* downloads 13-21 images per
|
|
580
|
+
article (~30s each - a 25-minute pilot processed 47 articles); and none of that is needed downstream, because
|
|
581
|
+
`create_reading_notes` reads only the md frontmatter (`title`/`source`/`url`/`topic`/`date`) plus the first ten
|
|
582
|
+
lines of the body, and `compile_wiki` reads only the `## AI 摘要` section. So this script reuses `biz_daily`'s
|
|
583
|
+
collection, topic-normalisation, md-writing and `.articles.json` functions - one implementation of each, not a
|
|
584
|
+
second copy - and changes exactly three things: fetch concurrently, do not download images, do not emit the HTML
|
|
585
|
+
reader. Measured over 2026-03-01..2026-08-31: 164 days, 25,603 articles, of which **12,386 actually need a
|
|
586
|
+
fetch** (the other half already carry usable text in the database), ~2.5-3 articles/second at 10 workers.
|
|
587
|
+
|
|
588
|
+
Two limits are stated rather than discovered later. **Image-only articles yield nothing**: measured on 3/4 and
|
|
589
|
+
7/4, 27/40 and 44/60 carried text, and the rest are 图文 messages whose content is in the pictures - the same
|
|
590
|
+
`内容过短` gate `biz_daily` applies, not a fetch failure. And the topic comes from `_guess_topic` (the keyword
|
|
591
|
+
fallback `biz_daily` uses when AI classification fails) rather than a model, because this step makes no AI calls
|
|
592
|
+
at all; the concept extraction that does is a separate, later command.
|
|
593
|
+
|
|
594
|
+
- **`article-notes --since / --until` - a date window, because "do all of September" is how a person says it.**
|
|
595
|
+
`--limit` can only express "the newest N", so using it to cover a month either misses articles (a busy month
|
|
596
|
+
exceeds the limit) or drags in the neighbouring month (a quiet one does not reach it). Measured on the real vault:
|
|
597
|
+
September holds 710 article notes, 120 already carded, so the exact job is 590 calls - a number `--limit` could only
|
|
598
|
+
have approximated. With the default incremental behaviour the already-carded 120 are skipped rather than re-asked.
|
|
599
|
+
|
|
600
|
+
- **Your own notes are now a source: the AI reads what you write and generates notes back.** The three existing
|
|
601
|
+
lines all read *material* - articles, conversations, favourites. This one reads **notes the user wrote**, which
|
|
602
|
+
changes what has to be guaranteed:
|
|
603
|
+
|
|
604
|
+
- **your notes are read-only.** Nothing in this line writes to, moves or renames them; the output is a separate
|
|
605
|
+
card under `output/user-notes/`. The failure mode here is asymmetric - corrupting someone's own writing is worse
|
|
606
|
+
than any of the other three lines going wrong - so the promise is stated in the command's own description.
|
|
607
|
+
- **the card's summary is marked `summary_by: model`** and the body says it in words: *this is the model's reading
|
|
608
|
+
of your note, not your words*. A model's paraphrase mistaken for the user's own writing is the worst confusion
|
|
609
|
+
this line could produce.
|
|
610
|
+
- **links the user wrote win.** If the note contains `[[权限边界]]`, that concept name is kept, ranked first, and
|
|
611
|
+
the model only fills in what the note did not mention - the user's own vocabulary is what they will search with
|
|
612
|
+
later. Verified on a demo note: 5 concepts came out, one of them the user's own link.
|
|
613
|
+
- **the prompt forbids completing the user's thoughts.** "Do not fill in what they left open, and do not pretend
|
|
614
|
+
to be certain where they were unsure" is the one instruction that differs most from the other three lines - a
|
|
615
|
+
note is where someone thinks out loud, and a model that tidies that up destroys the signal.
|
|
616
|
+
|
|
617
|
+
It reads only the human layers (`000_Inbox`, `003_Ideas`, `004_Permanent`, `005_Reference`, `006_Projects`,
|
|
618
|
+
`008_MOC`, `999_Archive`) plus loose `.md` files in the vault root, and never `Wiki/` (generated),
|
|
619
|
+
`001_Daily`/`002_Literature` (pipeline) or `Sources/` (raw material) - otherwise the AI would read its own output
|
|
620
|
+
back and drift. Pages derived from these notes are tagged `来源/我的笔记`, so `tag:#来源/我的笔记` separates
|
|
621
|
+
"what I thought" from "what I read" in the graph.
|
|
622
|
+
|
|
623
|
+
The loop closes: write a note → the AI makes concepts → edit a generated page and it is overwritten on the next
|
|
624
|
+
run, so anything worth keeping belongs in `004_Permanent` (a human layer), where the next run will read it back as
|
|
625
|
+
a source rather than discard it.
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
- **The generated vault now says four things it should have said all along**, borrowed from a local Obsidian vault
|
|
629
|
+
built for exam prep (studied at the user's request; its own usage doc is a good example of a generated knowledge
|
|
630
|
+
base that explains itself):
|
|
631
|
+
|
|
632
|
+
- **"This page is generated"** - re-running `wiki compile` overwrites the overview and every concept page, so a
|
|
633
|
+
note edited by hand disappears on the next run. Their vault states this in the first line of its usage doc; ours
|
|
634
|
+
said nothing, which is how a user loses an afternoon of editing.
|
|
635
|
+
- **"Model-generated, not human-verified"** - every concept page now carries `verified: false`. The borrowed
|
|
636
|
+
vault's README states the same rule ("unverified information must be marked, never presented as settled"), which
|
|
637
|
+
is also this project's own rule for drafts (`judged: false`); concept pages had no such marker.
|
|
638
|
+
- **A nested type tag** (`知识/概念`) so `tag:#知识/概念` finds every knowledge page - their vault uses nested tags
|
|
639
|
+
(`资料分析/增长`) exactly this way, and it costs nothing to make our pages filterable by kind.
|
|
640
|
+
- **How to open the graph without hanging**: the vault holds thousands of source notes, so a global graph is
|
|
641
|
+
unusable; the index now names the filter string (`path:"Wiki/Concepts"`) and the better habit (local graph).
|
|
642
|
+
Their doc does this with a table of filter strings, including one row that says "the panorama is slow, use with
|
|
643
|
+
care" - an honest note this project is happy to copy. The Obsidian config file itself is **not** written: our
|
|
644
|
+
vault has no `.obsidian` directory, and guessing a config schema would be worse than giving the user a string to
|
|
645
|
+
paste.
|
|
646
|
+
|
|
647
|
+
- **`wiki compile --relabel`** rewrites only the frontmatter of existing pages (type tag, verification marker) with no
|
|
648
|
+
model calls - the same trick as `article-notes --refresh-summaries`: metadata is computable locally, so a
|
|
649
|
+
convention change should not cost 51 regenerations. Idempotent, and tested as such.
|
|
650
|
+
|
|
651
|
+
### Fixed
|
|
652
|
+
|
|
653
|
+
- **The index page reported "0 references" for pages that had them.** Concept titles are written as `title: "甲"`
|
|
654
|
+
(YAML-safe) while the aggregation keys them bare, so the lookup missed and the page looked merely un-cited rather
|
|
655
|
+
than mis-counted.
|
|
656
|
+
- **The index could only ever show one source's view.** It is rewritten by every compile, and the three lines
|
|
657
|
+
(articles, conversations, favourites) are compiled separately - so the table was rebuilt each time against
|
|
658
|
+
whichever source ran last, and a favourite-derived concept showed 0 while its card was right there. Counts are now
|
|
659
|
+
taken across **all** `output/*-notes` directories, which also fixes the first bug's symptom.
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
- **WeChat favourites are the third source feeding the knowledge base.** The daily line covers what the user *read*;
|
|
663
|
+
favourites are what they deliberately **kept**, which includes articles outside the daily window and is a stronger
|
|
664
|
+
signal of interest. `fav-notes` reads the favourites (locally), takes each one's text where the record carries it and
|
|
665
|
+
otherwise fetches the article, then writes the same card shape the other two producers write - so the aggregator,
|
|
666
|
+
the health check and the assistant's search needed no changes. The fetch **reuses the daily line's cached
|
|
667
|
+
fetcher** rather than growing a second downloader: the WeChat-UA/WAF/gzip retry logic was hard-won once, and the
|
|
668
|
+
cache means favourites the daily line already fetched cost no network at all - measured on the first real run, all
|
|
669
|
+
four fetched bodies came from the cache.
|
|
670
|
+
|
|
671
|
+
One thing this line does differently and says so on the card: the article and chat lines **copy** an existing
|
|
672
|
+
summary, while a favourite record has none, so this summary is **model-written** and marked `summary_by: model`.
|
|
673
|
+
`saved` (when the user kept it) is deliberately not called `published`.
|
|
674
|
+
|
|
675
|
+
- **`wiki lint` finds card directories with a glob now** (`output/*-notes`), because hard-coding the two directories
|
|
676
|
+
meant a third source's inbound links would be silently missed - and the visible symptom of that is "every new page
|
|
677
|
+
is an orphan", which is worse than no check at all.
|
|
678
|
+
|
|
679
|
+
### Fixed
|
|
680
|
+
|
|
681
|
+
- **`fav-notes --dry-run` was fetching articles.** The preview's own description says "local only", but it classified
|
|
682
|
+
each favourite by calling the function that fetches - so a preview quietly made four HTTP requests. Classification
|
|
683
|
+
is now a separate, side-effect-free question (`local` / `needs-fetch` / `none`), the preview reports what *would*
|
|
684
|
+
be fetched without fetching it, and a test asserts that classifying an unreachable URL does not resolve it. Found by
|
|
685
|
+
running the preview, not by reading it.
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
- **The chat knowledge line was failing on long conversations, and the failure said nothing useful.** Two of three
|
|
689
|
+
conversations came back as "the model did not return usable JSON" - a reason that fits both "the output was
|
|
690
|
+
truncated" and "the model answered in prose", so it points at neither. The cause was truncation: the JSON a long
|
|
691
|
+
conversation has to produce (summary, timeline, topics, people) is token-hungry in Chinese and the call was capped
|
|
692
|
+
at 1200 tokens, so the JSON ended mid-object. The cap is now 2000 with a longer timeout, and a failed call records
|
|
693
|
+
the **tail of what the model actually returned**, so the next occurrence is diagnosable instead of guessable.
|
|
694
|
+
Measured after the change: 3 of 3 conversations succeed.
|
|
695
|
+
|
|
696
|
+
- **A partly-successful build was reported as a total failure.** Both `chat-notes` and `article-notes` exited
|
|
697
|
+
non-zero if *anything* failed, so "2 of 3 cards written" arrived at the CLI as a failure - and the CLI then
|
|
698
|
+
replaced the script's own JSON (which listed which one failed and why) with a bare `(exit 1)`. Partial success is
|
|
699
|
+
now exit 0 with the failures listed as data, only "nothing was written" is a failure, and the CLI's error path
|
|
700
|
+
carries the child's output so a real failure keeps its reason. (This is the same principle as the earlier fix to
|
|
701
|
+
the assistant prompt: an error that does not carry its cause invites a guess that is wrong.)
|
|
702
|
+
|
|
703
|
+
### Added
|
|
704
|
+
|
|
705
|
+
- **The knowledge base can now be checked, and the check found three things worth fixing.** `wiki lint`
|
|
706
|
+
(`scripts/wiki_lint.py`, `weflow-cli wiki lint`) reports broken links, orphan pages, empty pages and duplicate
|
|
707
|
+
titles - locally, with no model calls. The gap was recorded in D-049 as deliberately not done; running it against
|
|
708
|
+
the real 39 pages is what justified closing it, because two judgements in it are not obvious:
|
|
709
|
+
|
|
710
|
+
- **It separates "a concept the pages link to that has no page yet" from a break.** `## 相关概念` sections name
|
|
711
|
+
concepts the model proposed; only the most-referenced ones have pages. Reporting both kinds as "dead links"
|
|
712
|
+
gave "40 dead links" on a healthy knowledge base - the fastest way to teach a reader to ignore a tool. It now
|
|
713
|
+
reports expansions as candidates (131 of them) and breaks as breaks (0).
|
|
714
|
+
- **Card-side links count as inbound.** Without that, every single page was reported as an orphan, because pages
|
|
715
|
+
mostly link *forward* to concepts that do not exist yet - so page-to-page inlinks are nearly empty by
|
|
716
|
+
construction. Card links are the real inbound edges, and counting them turned "10 orphans" into the truth.
|
|
717
|
+
|
|
718
|
+
- **`wiki compile --min-refs` - a gate on what deserves a page.** Measured on the real corpus: 120 article notes
|
|
719
|
+
produce **387 concepts, of which only 37 are mentioned by more than one** - the rest are the people, companies and
|
|
720
|
+
single events of one news story. Writing a page each is not a knowledge base, it is a clippings file. `--min-refs 2`
|
|
721
|
+
keeps only the durable ones, and it is what the default recommendation is based on, not a guess.
|
|
722
|
+
|
|
723
|
+
- **`article-notes` is incremental by default.** It used to re-ask the model about every article on every run:
|
|
724
|
+
thirty articles meant thirty wasted calls, and the full 1633 would have meant a whole run's money. A card newer
|
|
725
|
+
than its source note now means "skip"; a changed source note means "redo"; `--refresh` forces everything. The
|
|
726
|
+
chat line keeps rewriting its cards on purpose - those are rolling snapshots.
|
|
727
|
+
|
|
728
|
+
- **`wiki lint` reports near-duplicate titles too**, because the same event arrives from several accounts. The real
|
|
729
|
+
run produced a pair of *pages for one event* (`尼泊尔热索瓦泥石流` and `热索瓦泥石流灾害`) - which the orphan check
|
|
730
|
+
cannot catch, since both have cards linking to them - and a four-source cluster around one news story, which is what
|
|
731
|
+
inflates a concept's rank (visible directly in a page's `sources`). Report only, no automatic merging: two accounts
|
|
732
|
+
writing about one event may genuinely carry different information, and deciding which page to drop is a person's
|
|
733
|
+
call.
|
|
734
|
+
|
|
735
|
+
- **`wiki lint` also reports degenerate fields** - a metadata column that is nearly one value. Measured: all 39
|
|
736
|
+
concept pages carry `topics: [学术]`, i.e. the field looks like metadata and says nothing, and it is the kind of
|
|
737
|
+
thing that gets trusted. (An independent measurement on the same corpus: of 224 notes whose titles read as news,
|
|
738
|
+
40 are labelled `学术`.) This reports the degeneracy rather than fixing the classifier - that classifier belongs to
|
|
739
|
+
the daily pipeline, and changing its criteria without labels would be a guess.
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
- **The article line can now produce knowledge pages, and this was the missing half of the pipeline.** Running the
|
|
743
|
+
aggregator over the whole Vault had shown that the 1633 article notes contain no concept links at all - only their own
|
|
744
|
+
topic and `## 关联网络` path links - so `wiki compile` had nothing to aggregate, no matter how it was invoked.
|
|
745
|
+
`article-notes` is the step that was missing: one model call per article, **asking only for concepts** (the summary is
|
|
746
|
+
copied from the existing note rather than regenerated, which removes one chance to invent something), writing a card
|
|
747
|
+
into `output/article-notes/` in exactly the shape the aggregator reads. Measured on this machine: 30 articles -> 110
|
|
748
|
+
concepts -> 10 concept pages plus an index (`Wiki/00-Overview.md`), and those pages are now searchable locally
|
|
749
|
+
(`vault search`, and the assistant's `search_knowledge`, which until today could only answer "the knowledge base has
|
|
750
|
+
not been generated yet"). The concepts that come out are specific ("标签配料表一致性", "椰子水全覆盖风险排查") rather than
|
|
751
|
+
taxonomy words, which is what the prompt asks for in so many words.
|
|
752
|
+
|
|
753
|
+
Two noise sources in the *upstream* material surfaced in that trial and are deliberately not papered over here: some
|
|
754
|
+
article notes carry the WeChat reader's own UI text inside their summary (`在小说阅读器读本章 去阅读`), which the card
|
|
755
|
+
copies verbatim because copying is the point; and the topic classifier puts unrelated stories in one bucket (a murder
|
|
756
|
+
case and a food-safety notice both came out as `学术`). Both belong to the daily pipeline rather than this one, and both
|
|
757
|
+
are recorded in `docs/PROJECT_STATE.md`.
|
|
758
|
+
|
|
759
|
+
- **The knowledge-card helpers are shared rather than copied.** `chat_notes` and `article_notes` produce the same kind of
|
|
760
|
+
card, so `note_path`, `plain` (the `[[...]]` stripper) and `SEPARATOR` now live in `_utils.py` - one implementation,
|
|
761
|
+
two callers - and `_utils` is deliberately dependency-light so the CI job that installs only `zstandard pycryptodome`
|
|
762
|
+
can still import it (importing `chat_notes` would have dragged in `nt_decrypt`).
|
|
763
|
+
|
|
764
|
+
- **Chat conversations can now feed the knowledge base, as notes the existing wiki pipeline aggregates.**
|
|
765
|
+
Until now the knowledge side had one source - `output/biz-daily`, the article line, over ten thousand files -
|
|
766
|
+
while conversations could only be searched, never condensed. `chat-notes` closes that: one knowledge card
|
|
767
|
+
per conversation (summary, timeline, topics and people, what is owed), written to `output/chat-notes/`, and
|
|
768
|
+
`wiki compile --source ./output/chat-notes` turns the `[[topic]]` and `[[person]]` links into concept pages,
|
|
769
|
+
because `compile_wiki` already takes a `--source` and already aggregates wikilinks. The three shapes asked
|
|
770
|
+
for therefore come out of **one** producer plus the aggregator that was already there: the card is the
|
|
771
|
+
per-conversation shape, and topic pages and people/timeline pages are what the aggregator makes of it. No
|
|
772
|
+
second aggregator, no new concept format, and backlinks and search keep working.
|
|
773
|
+
|
|
774
|
+
Four things in it are deliberate. **The `[[...]]` links are rendered by our code, never by the model**: the
|
|
775
|
+
model returns structured lists plus free text, and any `[[...]]` inside that free text is stripped, because
|
|
776
|
+
`scan_articles` collects *every* wikilink in a body and cannot tell who wrote it - a stray one would invent
|
|
777
|
+
a concept. **Thin conversations get no card** (under 3 messages or 60 characters) and are reported as
|
|
778
|
+
skipped rather than silently dropped: a three-line chat yields a card saying "they said two things", at the
|
|
779
|
+
cost of a model call and one contentless source in a concept page. **Cards roll, concepts accumulate** - one
|
|
780
|
+
file per conversation, rewritten on each run with its window recorded, so repeated runs cannot multiply; the
|
|
781
|
+
cards are a snapshot and the concept pages are the ledger. And the prompt tells the model it is a compiler
|
|
782
|
+
rather than a writer, so contradictions stay side by side instead of being smoothed into one sentence, which
|
|
783
|
+
is what a knowledge base of real conversations needs. That phrasing is borrowed from Tencent/WeKnora's wiki
|
|
784
|
+
prompts, read for this work - as is the "too little content, do not call the model" gate.
|
|
785
|
+
|
|
786
|
+
Verified by running it: `chat-notes --dry-run` reports 37 conversations, 2913 messages and 105,607
|
|
787
|
+
characters over 30 days on this machine, and names the 3 that are too thin to card.
|
|
788
|
+
|
|
789
|
+
- **`wiki compile` now uses the per-topic description it was already collecting and throwing away.** The
|
|
790
|
+
aggregator collects, for every wikilink, the sentence the source wrote *about that concept* - and page
|
|
791
|
+
generation only ever used the note's overall summary. A concept mentioned in twenty notes was therefore
|
|
792
|
+
described by five of their generic summaries, never by what any of them actually said about it. Reference
|
|
793
|
+
lines now prefer that description and fall back to the summary, which is also what makes a person page read
|
|
794
|
+
as a timeline: each contributing line is "what happened then".
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
- **The panel's ball wears the project mascot, with a transparent background.** `resources/panel/mascot.png`
|
|
798
|
+
is served through the panel's static whitelist and drawn with **no disc behind it** - a mascot floating on
|
|
799
|
+
the desktop rather than sitting on a plate - separated from light wallpapers by a `drop-shadow` that
|
|
800
|
+
follows its outline. Three things about it were measured rather than eyeballed: the source
|
|
801
|
+
(`weflow-cli图标.png` at the repo root) is 1024x1024 but its **content occupies only 600x689**, so the
|
|
802
|
+
shipped asset is cropped to the alpha bounding box and resized to 256 (41KB rather than 1.37MB), which is
|
|
803
|
+
what makes the mascot fill the space instead of floating small inside it; the source's background is
|
|
804
|
+
**transparent**, so nothing needs keying; and the outline the ball used to have (a 1px light ring, drawn
|
|
805
|
+
for the disc) had to go with it - with no disc it draws a circle in empty space. Dropping the ring is
|
|
806
|
+
easy to forget, so a test asserts the two changes travel together.
|
|
807
|
+
|
|
808
|
+
An earlier revision of this entry claimed the transparent version "loses the cat's body into a dark
|
|
809
|
+
background". **That was my misreading of a small comparison image** in which the mascot was rendered too
|
|
810
|
+
small; at the real size (76 logical pixels, inspected at 3x against light, mid and dark backdrops) it
|
|
811
|
+
reads fine on all three. The user picked transparent after seeing both.
|
|
812
|
+
|
|
813
|
+
The **tray icon is a separate asset** (`tray.png`) with the disc baked in: at 16-24 pixels on a dark
|
|
814
|
+
taskbar there is no drop-shadow to lean on, so the disc is what keeps the outline legible. `nativeImage`
|
|
815
|
+
cannot composite, so that one is generated with a canvas and committed.
|
|
816
|
+
|
|
817
|
+
- **The ball's background moves, and it says what the assistant is doing.** Behind the mascot there is now
|
|
818
|
+
a soft-edged glow (deliberately not a hard disc - a hard edge is "sitting on a plate" again) whose hue
|
|
819
|
+
drifts on a 26-second loop, slow enough that it is only noticeable if you look at it. Its **state**
|
|
820
|
+
changes are the useful half: while a turn is in flight it brightens and breathes, and when the daemon
|
|
821
|
+
cannot be reached it goes warm and **stops moving** (a still thing is what gets noticed). A collapsed ball
|
|
822
|
+
previously gave no sign at all that it was working. `prefers-reduced-motion` turns all of it off, and the
|
|
823
|
+
three state classes are set from one function so a branch cannot forget to clear `busy` and leave the ball
|
|
824
|
+
glowing "working" forever - a test asserts that, and that no other code touches those class names.
|
|
825
|
+
|
|
826
|
+
Two details that came out of looking at the renders rather than the code: the breathing animation's
|
|
827
|
+
`scale(1.08)` overflowed the 76x76 window and produced a **scrollbar** inside the ball (visible as
|
|
828
|
+
up/down arrows in a screenshot), so ball mode is `overflow: hidden`; and the glow is a separate layer from
|
|
829
|
+
the mascot so the background can animate while the cat stays put.
|
|
830
|
+
|
|
831
|
+
- **The panel window no longer opens to an empty void.** It used to show nothing but black until you
|
|
832
|
+
typed, which says neither what the assistant can do nor that it is alive. It now opens with a
|
|
833
|
+
one-line explanation (including that the database never leaves the machine) and four **clickable**
|
|
834
|
+
example questions that submit through the same path as typing. Verified end to end by clicking one
|
|
835
|
+
through the DevTools protocol: the example block gives way, the question becomes a real turn, and
|
|
836
|
+
the answer comes back through the shared quota counter (`[panel] 9字 → 已回复 (427字, 今日 1/100)`).
|
|
837
|
+
|
|
838
|
+
- **A local panel: talk to the assistant without logging into the WeChat channel.** Until now the only
|
|
839
|
+
way in was the WeChat Bot channel, which requires scanning a QR code - so asking one question cost a
|
|
840
|
+
login. `weflow-cli panel` opens a small always-on-top window that talks to the same brain: the same
|
|
841
|
+
memory, the same daily quota, the same serial queue. It works with the channel logged out. Memory is
|
|
842
|
+
shared rather than duplicated because facts are stored per conversation id and the panel resolves to
|
|
843
|
+
the **single** allowlisted id when there is exactly one; with none or several it does not guess - it
|
|
844
|
+
uses its own bucket and **says so on screen**, because "separate memory that you think is shared" is
|
|
845
|
+
the exact failure this arrangement invites. `assistantPanelUser` pins the choice.
|
|
846
|
+
|
|
847
|
+
The window is a client, never a second assistant (D-045). `weflow-cli panel --status --json` reports
|
|
848
|
+
it, `panel --ask "…"` asks a question from the terminal, and both go through the same loopback
|
|
849
|
+
endpoint the window uses. The endpoint binds `127.0.0.1` and cannot be configured otherwise
|
|
850
|
+
(continuing D-004), but it is deliberately stricter than the daily reader: **every** request needs a
|
|
851
|
+
per-run token, Origin is a second gate, and POSTs must be JSON. The token never reaches a command
|
|
852
|
+
line - the Electron shell reads the endpoint file itself and installs the token as an `HttpOnly`
|
|
853
|
+
cookie, while the browser fallback uses a one-time 60-second code.
|
|
854
|
+
|
|
855
|
+
Also in this change, because the panel made them visible: `assistant start` **no longer refuses to
|
|
856
|
+
start when the WeChat channel is not logged in** (it used to throw before doing anything, so
|
|
857
|
+
"not logged in" meant "no assistant at all" - a field observation recorded in PROJECT_STATE), and
|
|
858
|
+
`assistant status` gained `channelActive` / `mode` / `panelPort` / `memoryBucket`, because
|
|
859
|
+
`messageChannelLoggedIn` only ever answered "is a token configured".
|
|
860
|
+
|
|
861
|
+
**The ball needs Electron, and Electron is not a dependency**: with none installed, `panel` falls
|
|
862
|
+
back to Edge/Chrome `--app` - a small window without an address bar, but **not** a floating ball (no
|
|
863
|
+
frameless, no always-on-top, no tray, no global hotkey), and the command says so. Install Electron
|
|
864
|
+
(`npm i -g electron`, or `npm install` in a checkout - the binary is downloaded by its own install
|
|
865
|
+
script) and the same command opens the ball. Verified on Windows 11 with Electron 42: 76x76, no
|
|
866
|
+
caption, always-on-top, the renderer authenticates through the cookie, and the ball is visible on
|
|
867
|
+
screen, and the interactive paths were driven without a click (DevTools protocol for the ball-to-chat
|
|
868
|
+
resize, a differential registration test for the hotkey). Doing that found two bugs a screenshot
|
|
869
|
+
could not have shown: collapsing left the window at chat size because a non-resizable window
|
|
870
|
+
ignores `setSize` on Windows, and the window sometimes never appeared because `ready-to-show` was
|
|
871
|
+
listened for only after the load. A third came out of running what the tray items actually do: the
|
|
872
|
+
"quit and stop the assistant" action spawned the CLI in a form commander rejects inside Electron's
|
|
873
|
+
Node mode. Only a click on the tray menu itself remains untested; each item's effect has been run
|
|
874
|
+
directly.
|
|
875
|
+
|
|
876
|
+
The ball also now **sits in the bottom-right corner and stays where you drag it**. It used to open
|
|
877
|
+
wherever Windows felt like it (measured 815,418, mid-left) and forget the position on every
|
|
878
|
+
restart. The remembered spot is checked before use: if it is no longer reachable - a monitor was
|
|
879
|
+
unplugged, or the ball was dragged off-screen - it falls back to the corner instead of leaving the
|
|
880
|
+
ball somewhere invisible. Expanding to the chat window and collapsing back re-fit into the work
|
|
881
|
+
area, so a 420x560 window no longer hangs off the screen when opened from a bottom-right corner.
|
|
882
|
+
|
|
883
|
+
- **The four "search" tools now name each other.** They search four different stores - chat logs,
|
|
884
|
+
the knowledge base, assistant memory, and a semantic index over chats - and each description used to
|
|
885
|
+
explain only what it searched, not how it differed from its siblings. `search_knowledge` did not say
|
|
886
|
+
it is *not* chat history; `search_memory` did not say what "memory" means here. Each now names the
|
|
887
|
+
store it covers and points at the others. Three eval cases pin the choice ("where did I mention X" must
|
|
888
|
+
use the chat search and must **not** reach for the knowledge base), which is the part that was never
|
|
889
|
+
measured.
|
|
890
|
+
|
|
891
|
+
Found while writing those cases: **an empty fixture invites retries.** With a stubbed search that
|
|
892
|
+
returns nothing, the model re-queries with different keywords - seven calls in one run, each with
|
|
893
|
+
different arguments, so the identical-call guard cannot help. That is the fixture's doing, not a
|
|
894
|
+
defect, so the fixture now returns a hit (and where it cannot, the count sits in the soft budget).
|
|
895
|
+
|
|
896
|
+
- **Tools this machine cannot run are no longer offered to the model.** `search_semantic` needs a
|
|
897
|
+
`dashscopeApiKey` and `get_weread` needs `wereadApiKey`; with neither configured the tools were still
|
|
898
|
+
in the tool list, so the model tried them and got errors back - the eval's ambiguous-contact case
|
|
899
|
+
ended with a reply that reported "both search tools failed", which is not the assistant's fault but
|
|
900
|
+
ours for offering a tool that cannot run. The list is now built by `availableToolDefs()`, which drops
|
|
901
|
+
a tool when its prerequisite is missing, and the fast path - which dispatches **directly**, bypassing
|
|
902
|
+
the list - goes through the same predicate before dispatching. The filter is deliberately narrow: it
|
|
903
|
+
only drops tools that *cannot* run, never tools that merely have no data yet, because "run
|
|
904
|
+
`weflow-cli wiki compile` first" is a useful answer while an authentication error is not.
|
|
905
|
+
|
|
906
|
+
- **The eval can now run a conversation, and it separates floors from expectations.** Multi-turn
|
|
907
|
+
cases let it check the half of memory that was never verified: storing a fact was covered, **using** it
|
|
908
|
+
was not. `memory-recall` says "remember I'm allergic to peanuts", then asks what to order for dinner;
|
|
909
|
+
the hard floor is that the fact is in long-term memory, and whether the reply brings it up is reported
|
|
910
|
+
as a soft expectation - it failed in one run out of three, and an intermittent red trains people to
|
|
911
|
+
ignore the report. Same principle applied across the board: every `maxTools: 3` became a runaway guard
|
|
912
|
+
(6) plus an efficiency budget (3), because the same question produced anything from 2 to 7 calls. The
|
|
913
|
+
criterion is one line: **if the number or expectation moves with the model's route or wording, it is a
|
|
914
|
+
budget, not a floor.** The only hard cap left is `no-tool`'s 0 - calling a tool when none is needed is
|
|
915
|
+
a real defect. And `unknown-contact` stopped asserting on wording: the model's way of saying "not
|
|
916
|
+
found" is unbounded (没找到 / 查不到 / 不存在 / …), so that case now asserts the **observable fact**
|
|
917
|
+
that the tool call returned nothing.
|
|
918
|
+
|
|
919
|
+
- **A reading-stats tool, and the eval learned to tell a floor from a budget.** `get_reading_stats`
|
|
920
|
+
answers "what have I been reading / which accounts post the most" from the local archive (it reuses
|
|
921
|
+
`daily_stats.py` rather than recomputing the same numbers). Its second purpose is honesty about
|
|
922
|
+
coverage: when the daily has not run, every account's processed count is zero, and saying "nothing was
|
|
923
|
+
processed in these 7 days; the last report with content was 2026-09-05 (19 days ago)" is a different
|
|
924
|
+
statement from letting the user believe those accounts had no content. That is how the staleness was
|
|
925
|
+
found - a 7-day window showed all zeros while a 30-day window did not, and the difference was not the
|
|
926
|
+
accounts.
|
|
927
|
+
|
|
928
|
+
The eval now separates two things it had conflated: **floors are hard, budgets are soft.** The
|
|
929
|
+
ambiguous-contact case was failing intermittently on a tool-call cap of 5 while every single run
|
|
930
|
+
correctly asked which person was meant - the same question produced anywhere from 2 to 7 calls, so
|
|
931
|
+
the cap was measuring the model's route rather than a defect. `maxTools` is now only a runaway guard
|
|
932
|
+
(raised to 8) and `toolBudget` reports exceeding the budget as a warning that never fails the run.
|
|
933
|
+
A flaky case is worse than no case: it teaches people to ignore the report.
|
|
934
|
+
|
|
935
|
+
- **`contacts -k` only searched the first N rows, and the assistant's name lookup had a blind spot.**
|
|
936
|
+
Two defects found by trying to resolve a person by name on real data. First, the keyword filter ran
|
|
937
|
+
**after** `LIMIT`: `get_contacts` fetched the first `limit` rows of `Name2Id` and only then filtered
|
|
938
|
+
them, so on a 500-contact address book anyone past row 200 was invisible to a search - measured, a
|
|
939
|
+
lookup by remark hit 3 of 10. The filter now runs first and truncates afterwards (extracted as
|
|
940
|
+
`filter_contacts`, with tests). Second, `resolveTalker` searched only the most recent **300 sessions**;
|
|
941
|
+
a name that is not in them fell through to "treat the query as a talker id", so the assistant reported
|
|
942
|
+
"no messages" for someone who is plainly in the address book. Resolution now falls back to the contact
|
|
943
|
+
book, matching remark / display name / nickname / alias / username, and still refusing when the match
|
|
944
|
+
is not unique. Measured on 378 real contacts that are outside the recent sessions: **362 resolved, 10
|
|
945
|
+
correctly refused for duplicate names, 0 ever resolved to the wrong person**, 5 single-character names
|
|
946
|
+
still miss - and a miss is safe, because it returns null and the assistant says it could not find
|
|
947
|
+
them rather than reading someone else's chat.
|
|
948
|
+
|
|
949
|
+
- **The assistant can see time now, and can reach a specific day.** Two halves of one gap: the
|
|
950
|
+
system prompt carried no current date, so "上周三" had nothing to resolve against, and `get_messages`
|
|
951
|
+
took only a message count - meaning a day far enough back was simply unreachable (the model either
|
|
952
|
+
said it could not find it or, worse, answered from the most recent messages as if they were that
|
|
953
|
+
day's). The prompt now opens with `[当前时间] 2026-09-23(星期三)09:05`, and `get_messages` accepts
|
|
954
|
+
`since`/`until` (`2026-09-16`, or relative `3d` / `2w` / `12h`, resolved by day boundary rather than
|
|
955
|
+
an exact 24 hours because that is what people mean). With a window it reads through
|
|
956
|
+
`getMessagesInRange` and says which window it used; without one, behaviour is byte-identical to
|
|
957
|
+
before. An unparseable time is a readable parameter error - guessing a window would be worse, since
|
|
958
|
+
the model would then believe it had queried the period the user named.
|
|
959
|
+
|
|
960
|
+
- **The same call twice in one turn is now skipped.** The eval caught this rather than a person: its
|
|
961
|
+
`ambiguous-contact` case produced **seven** tool calls, three of them the identical `list_sessions`.
|
|
962
|
+
Repeating an identical call cannot return anything new, so the loop now returns a note
|
|
963
|
+
("this step is identical to an earlier one; use the result already in the conversation") instead of
|
|
964
|
+
executing, and records the skip in the trace. The case dropped to four calls and passes.
|
|
965
|
+
|
|
966
|
+
- **The assistant now records what it did on the way to an answer, and you can read it.**
|
|
967
|
+
`weflow-cli assistant trace` prints the last few turns, and sending `轨迹` in WeChat returns the previous
|
|
968
|
+
one. Each record carries the fast-route's decision, every tool call with a **redacted argument summary**
|
|
969
|
+
and the size of what came back, how many model round trips it took, and why it stopped
|
|
970
|
+
(`answered` / `rounds-exhausted` / `llm-error` / `builtin`). Until now the only output was the reply:
|
|
971
|
+
`TURN_DONE tools=5` said five tools were called and nothing about which five or with what arguments -
|
|
972
|
+
which is exactly the question that could not be answered the one time it mattered (a turn that called
|
|
973
|
+
`search_favorites` twice and `read_favorite` twice).
|
|
974
|
+
|
|
975
|
+
Two things are deliberately separate. The **audit** is unchanged: events and byte counts, never
|
|
976
|
+
content, because it is the egress record. The **trace** is a local debugging artifact and does contain
|
|
977
|
+
argument summaries, redacted and truncated at 40 characters per value; the in-chat rendering leaves the
|
|
978
|
+
machine (as the reply already does) and omits `userId`.
|
|
979
|
+
|
|
980
|
+
On chain of thought: the trace carries whatever reasoning the provider returns in
|
|
981
|
+
`reasoning_content`, clipped at 800 characters. The default `deepseek-chat` does not return any, so the
|
|
982
|
+
field is empty and the trace says "无" rather than implying it thought something. Switch to a model that
|
|
983
|
+
returns it (`config set aiModel deepseek-reasoner`) and the text shows up in the CLI view; it is never
|
|
984
|
+
fed back into the conversation, since it is the model's monologue rather than an answer. See D-043 for
|
|
985
|
+
the boundaries - including why "did this step produce anything" is a documented convention with a
|
|
986
|
+
test-guarded whitelist rather than a real outcome field.
|
|
987
|
+
|
|
988
|
+
- **The assistant has a behaviour eval now** (`npm run eval:assistant`). Until this, there was no way
|
|
989
|
+
to know whether the assistant was any good: the unit tests all inject a fake model (they prove the
|
|
990
|
+
code paths still work, not what a model does with 16 tools), and the only other feedback was talking
|
|
991
|
+
to it in WeChat and noticing problems by luck. The eval runs twelve synthetic cases against the real
|
|
992
|
+
model and asserts **floor conditions**, not quality: did it call the tool that could answer the
|
|
993
|
+
question, and did it not reach for tools when none was needed; did tool usage stay bounded (one
|
|
994
|
+
question in the archive used five calls); does a **failing** tool get reported as failing - the case
|
|
995
|
+
that earns its keep, since a script returning exit code 2 produces a reply that quotes the error,
|
|
996
|
+
names the likely cause and offers an alternative, instead of "no one is waiting on you"; is an
|
|
997
|
+
unknown contact ever given invented content; and is memory judged by **the outcome** (the fact
|
|
998
|
+
landing in long-term memory) rather than by whether `save_memory` was called.
|
|
999
|
+
|
|
1000
|
+
Data is synthetic and the whole run happens in a temporary home directory, so the real audit log and
|
|
1001
|
+
memory file are untouched - writing the memory file from a second process would otherwise clobber
|
|
1002
|
+
whatever the daemon had just written. Only the model call leaves the process; any other request
|
|
1003
|
+
throws. The key is read from the real config (a `lock:` ciphertext that only decrypts on this
|
|
1004
|
+
machine) and the temporary config holds that one field and nothing else.
|
|
1005
|
+
|
|
1006
|
+
Three limits are documented with it, because they are the difference between a useful instrument and
|
|
1007
|
+
a reassuring one: the expectations are the author's, not human labels, so this measures floors rather
|
|
1008
|
+
than quality; a first run that comes back green proves little, since cases and behaviour share an
|
|
1009
|
+
author - the value is in re-running it after a change; and **false failures are a real hazard**: this
|
|
1010
|
+
suite's own first version asserted the model would say "查不到" and failed twice on "查不了", so
|
|
1011
|
+
assertions now key off the tool's own diagnostics (error text, exit code) rather than the model's
|
|
1012
|
+
wording. The run history, kept as it happened rather than tidied: **6/8** (one fixture bug - the
|
|
1013
|
+
`getMessages` stub ignored the talker, so a non-existent contact was served another conversation's
|
|
1014
|
+
messages and the assistant reported them as theirs - and one false failure) → **8/8** → **8/8** →
|
|
1015
|
+
**7/8** (the same false failure again) → **8/8, 8/8, 8/8** after the assertion was rewritten. Both
|
|
1016
|
+
failures were the assertion's fault, not the assistant's.
|
|
1017
|
+
|
|
1018
|
+
Four more cases followed (favourites search, a refused fetch of an internal address, two asks in one
|
|
1019
|
+
message, an ambiguous contact name - the last two guard "ask which one instead of guessing" and "serve
|
|
1020
|
+
both halves"), and their first runs produced **11/12 twice**, again entirely the assertions' fault:
|
|
1021
|
+
one case demanded a refused fetch be described with words the model did not use ("抓不了" versus its
|
|
1022
|
+
"抓不下来"), and another was capped at three tool calls when four was reasonable exploration. The
|
|
1023
|
+
rewrite settled on rules now written into OPERATIONS: **match stems rather than whole words**, **never
|
|
1024
|
+
assert on a forbidden word** (the model said "this is a local problem, **not** that nobody is waiting
|
|
1025
|
+
on you" and the regex read it as the opposite), and ask before tightening a cap. Three consecutive
|
|
1026
|
+
12/12 runs after that.
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
- **The calibration harness now covers what the report actually decides, and gained a half that needs
|
|
1030
|
+
no labels at all.** `quality_eval.py` sampled articles and asked you to label `topic` + `include`;
|
|
1031
|
+
`relevance` was not in that list even though **it** is what the admission rule reads. The label file
|
|
1032
|
+
now has three fields, `score` reports relevance agreement **and the mean error in levels** (two
|
|
1033
|
+
articles both labelled 中, one 0.4 too high and one 0.4 too low, score 100% accuracy while the score
|
|
1034
|
+
is visibly misaligned - accuracy cannot see that), and there is now a second threshold sweep: the
|
|
1035
|
+
relevance cuts `0.5`/`1.5` were written with the comment "暂定" because there was nothing to calibrate
|
|
1036
|
+
them against, and this is the thing that calibrates them. The printed sheet is **blind** - it no
|
|
1037
|
+
longer shows Jev's answer next to each title, because seeing it first is an unmeasurable inflation of
|
|
1038
|
+
the agreement being measured.
|
|
1039
|
+
|
|
1040
|
+
- `quality_eval.py consistency <file>` is the **label-free** half: it reports how often the two
|
|
1041
|
+
questions answer the same article two ways (high relevance with nothing usable, or low relevance with
|
|
1042
|
+
something usable). This came from `jev-chat-jarvis` (the vendored reference implementation of this
|
|
1043
|
+
exact pattern), whose task spec makes "questions must not contradict each other" a hard requirement
|
|
1044
|
+
and then needs a labelled set to enforce it - but a contradiction is self-evident, so this number is
|
|
1045
|
+
available today, without a single human label. Items missing either score are counted as undecidable
|
|
1046
|
+
rather than as agreement, which is the same discipline the rest of this repo applies to missing
|
|
1047
|
+
values.
|
|
1048
|
+
|
|
1049
|
+
- `jev_probe.py --criteria-ab` asks the same articles twice, current criteria (Chinese, score bins that
|
|
1050
|
+
name an abstraction level) against an English variant whose score bins describe concrete scenes, both
|
|
1051
|
+
rules taken from `jev-chat-jarvis`'s hard constraints. Measured here on 24 and 12 real articles: topic
|
|
1052
|
+
agreement 79% and 67%, relevance raw-score mean absolute difference 0.17 and 0.24, disagreements
|
|
1053
|
+
concentrated on the AI↔学术 boundary (a paper about a method) and 文学↔新闻. **These numbers say what
|
|
1054
|
+
a wording change moves, not whether it improves anything** - that still needs the labelled set, which
|
|
1055
|
+
is why the variant stays a candidate and the production criteria are unchanged.
|
|
1056
|
+
|
|
1057
|
+
- **The assistant can look at a picture.** 15.5% of the messages in one measured 30-day archive are
|
|
1058
|
+
images (242 of 1564), and the model used to see `[图片]` and nothing else - the largest remaining gap,
|
|
1059
|
+
and one no amount of prompt work closes. `get_messages` now renders an image as `[图片 #1234]`, and a
|
|
1060
|
+
new `look_at_image` tool takes that number, decrypts that one image **locally**, and attaches it to the
|
|
1061
|
+
next request as a real image. Looking is on demand: one image per call, at most two per turn.
|
|
1062
|
+
|
|
1063
|
+
**An image is data leaving the machine, and it is treated as such.** `strict` mode (`assistantPrivacy`)
|
|
1064
|
+
holds images back at two layers - the tool refuses to fetch, and the request builder drops anything that
|
|
1065
|
+
got through anyway - and a drop is stated in the message body rather than happening silently. The
|
|
1066
|
+
`#N` handle is not even shown in `strict` mode: advertising something the tool will certainly refuse is
|
|
1067
|
+
worse than not mentioning it. The audit gained `IMAGE_SENT` and `IMAGE_HELD` lines (byte counts and
|
|
1068
|
+
message ids, never content).
|
|
1069
|
+
|
|
1070
|
+
Reading is 0.9 s for a direct chat and up to ~19 s for a group with 30k messages, because the media
|
|
1071
|
+
index is rebuilt per read; a resolved image is cached under `output/.cache/read-image/`, so a second
|
|
1072
|
+
look at the same picture is cheap. Images never enter memory. See D-042 for the boundaries, and note
|
|
1073
|
+
that this makes a third-party vision model part of the loop - the same content already went to
|
|
1074
|
+
DeepSeek as text, but images are a new class of it.
|
|
1075
|
+
|
|
1076
|
+
- **Drafted replies** (`weflow-cli draft <联系人>`, and `draft_reply` in the assistant). Given one
|
|
1077
|
+
conversation it asks a decision model seven typed questions - what the other side is after, what they
|
|
1078
|
+
need, which action type fits, whether substance is owed, how risky it is (0-9), whether money is
|
|
1079
|
+
involved, whether a commitment is outstanding - then writes and ranks candidate replies. The pattern is
|
|
1080
|
+
borrowed from `jev-chat-jarvis`; the two things it does *not* do are the two worth doing, and both were
|
|
1081
|
+
read from its source: its risk level is only a badge colour (it never refuses a draft), and its judgement
|
|
1082
|
+
never reaches the drafting prompt. Here the judgement **is** injected, and the user's own earlier
|
|
1083
|
+
messages are offered as a tone sample. **It produces text and stops there** - no input-box filling, no
|
|
1084
|
+
key simulation, no focus stealing; `capabilities --json` reports `sendsNothing: true`.
|
|
1085
|
+
|
|
1086
|
+
Money, or risk >= 7, means **no draft at all**: you get what the risk is and what to confirm first. That
|
|
1087
|
+
is the answer, not a failure. The 0-9 threshold is **picked, not calibrated**, so the band explains the
|
|
1088
|
+
refusal and does not decide alone; an outstanding commitment deliberately does *not* refuse (that is
|
|
1089
|
+
exactly when a draft helps) and constrains the prompt instead (`--gate-commitment` makes it hard).
|
|
1090
|
+
Three model calls per run, all reported by `--dry-run` before anything leaves the machine. In the
|
|
1091
|
+
assistant the transcript is masked first and handed over on stdin, and `strict` mode refuses outright -
|
|
1092
|
+
drafting has to send the body to remote models, and drafting from a body masked into "content N
|
|
1093
|
+
characters" would produce a fluent answer built on nothing. Text in the panel now carries a copy button,
|
|
1094
|
+
which says so when the clipboard write fails instead of claiming success. See D-047.
|
|
1095
|
+
|
|
1096
|
+
Two things about the judgement are recorded rather than settled: the criteria are **English** (following
|
|
1097
|
+
the reference implementation, whose `TASK.md` states that is the model's main training language) while
|
|
1098
|
+
the money/commitment questions are reused **verbatim in Chinese** from `reply_debt` - two wording
|
|
1099
|
+
conventions against one model with **no A/B run**; and nothing here is calibrated against a gold
|
|
1100
|
+
standard, so "is this draft any good" has only your own reading as a check.
|
|
1101
|
+
|
|
1102
|
+
- `strict` mode now keeps the **type** of a non-text message. Its own rule says "time, direction and type
|
|
1103
|
+
only", and masking `[图片]` / `[文件] Base.csv` into `[内容4字已按严格模式屏蔽]` was throwing the type
|
|
1104
|
+
away too - the model could not tell an image from a text message, and learned nothing that was not
|
|
1105
|
+
already allowed. The payload is still withheld. A message the user *typed* that happens to start with
|
|
1106
|
+
`[图片]` is still masked as text: only the reader's own non-text labels count as labels.
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
- **Right-click in the panel for a quick reply.** A new `quickReplyContacts` config key
|
|
1110
|
+
(`weflow-cli config set quickReplyContacts "咸鱼梦想家,老王"`) holds the contacts the menu offers; picking one asks the
|
|
1111
|
+
assistant to draft candidate replies for that person through the existing `/api/ask`, so quota, the serial queue, memory and
|
|
1112
|
+
the copy button all come along unchanged. The list rides on `/api/status`, which the page already polls every 30 seconds -
|
|
1113
|
+
no new endpoint, no new IPC, and the preload surface is untouched. Three details are deliberate: the menu **states its own
|
|
1114
|
+
cost** ("drafting sends this conversation to two cloud models") instead of letting you find out afterwards, an empty list
|
|
1115
|
+
shows the command to run rather than an empty box, and the menu is **native** (built in the main process), so it draws
|
|
1116
|
+
outside the window - the ball is 76x76 and never expands for it. (The first version did expand it, and the user said what
|
|
1117
|
+
that felt like: right-click makes the 第二大脑 window pop up.) The browser fallback does not take over right-click at all -
|
|
1118
|
+
the native menu's Copy belongs to the user.
|
|
1119
|
+
|
|
1120
|
+
Two follow-ups from using it: the window now **raises and focuses itself when it expands** (the chat window is not
|
|
1121
|
+
always-on-top by design, and closing a native popup can hand focus back elsewhere - the report was "I do not know where
|
|
1122
|
+
the answer went"), and the conversation shows a readable turn (`快速回复:<name>`) while what is actually sent is the
|
|
1123
|
+
explicit tool-naming request, so the chat log does not fill up with machine-shaped instructions.
|
|
1124
|
+
|
|
1125
|
+
- **The same right-click menu can now put the ball away.** Its last item is the only one in that menu that reaches no
|
|
1126
|
+
model at all: it hides the window instead of quitting the panel, so it is reversible by design - and that is exactly
|
|
1127
|
+
why the label names the way back (`关闭悬浮球(托盘图标能再打开)`). The ball runs with `setSkipTaskbar(true)`, so
|
|
1128
|
+
once it is hidden there is nothing on the taskbar to click; a label that just said "close" would read as "it is gone
|
|
1129
|
+
for good". Keeping "hide" and "exit" apart is the same line the tray menu already draws (its two exit items sit after
|
|
1130
|
+
a separator, away from show/expand), so no exit action was added here. The item is also the **only one present when
|
|
1131
|
+
the contact list is empty** - a user who never configured `quickReplyContacts` would otherwise get a menu of two grey
|
|
1132
|
+
lines that does nothing when clicked, including no way to dismiss the ball.
|
|
1133
|
+
|
|
1134
|
+
- **The semantic index's window is adjustable, and the preview says what it is.** How far back the index reaches was
|
|
1135
|
+
hardcoded at the two call sites, and neither the command nor its preview mentioned it at all: 90 days of chat and 30
|
|
1136
|
+
date-directories of articles. So "a personal knowledge base" quietly meant "the last three months", with nothing on
|
|
1137
|
+
screen saying so. `search-index` now takes `--days` and `--article-days`, and both the read-only preview and the
|
|
1138
|
+
confirmation prompt state the window they will use - the window is part of the cost, because more days means more
|
|
1139
|
+
text to the embedding service. **The defaults are unchanged (90 / 30)**: this makes a fixed number adjustable rather
|
|
1140
|
+
than changing what a build costs. Out-of-range values are refused before anything is read, with `0` called out
|
|
1141
|
+
specifically - a zero-day window builds an *empty* index that looks like a successful build, which is the same shape
|
|
1142
|
+
of failure as the `collect_articles` glob bug that once made the article half of the index silently empty. The
|
|
1143
|
+
script also reports the effective window in its JSON, which the CLI passes through, so a real run states what it
|
|
1144
|
+
used rather than leaving the user to infer it.
|
|
1145
|
+
|
|
1146
|
+
- **The interactive menu is the last extension point that had nothing watching it - now it has a guard.**
|
|
1147
|
+
`test/cli-menu.test.ts` requires the menu's entries and `showInteractiveMenu`'s `switch` cases to match **in both
|
|
1148
|
+
directions**, and every `runCmd` / `runSubCmd` the menu calls to name a command that actually exists. Both failures are
|
|
1149
|
+
silent by construction: an entry with no `case` does nothing when picked, and `runCmd` is written as
|
|
1150
|
+
`if (cmd) await ...`, so a renamed command makes an entry do nothing too. The command list is taken from the CLI's own
|
|
1151
|
+
`--help` rather than from the source, because the source cannot tell the two apart - 64 `.command('...')` call sites
|
|
1152
|
+
against 44 real commands. Four mutations (rename a menu value, rename a `case`, point `runCmd` at a missing command,
|
|
1153
|
+
point `runSubCmd` at a missing subcommand) each turn a specific assertion red, and `docs/EXTENDING.md`'s recipe C no
|
|
1154
|
+
longer carries a "nothing will tell you" caveat.
|
|
1155
|
+
|
|
1156
|
+
- **`docs/EXTENDING.md` - what to touch when adding to this project, and what will catch you.** The repository had no
|
|
1157
|
+
document for its most common kind of change, so "add an assistant tool" existed only as tribal knowledge spread over
|
|
1158
|
+
several files while "add a config key" was a comment at the top of a test. The guide gives one recipe per extension
|
|
1159
|
+
kind (assistant tool, config key, CLI command, hand-written MCP tool, Python workflow), each with the places to touch
|
|
1160
|
+
and **the test that fails if you miss one** - and, just as usefully, the two places where *nothing* will fail: the CLI
|
|
1161
|
+
command's interactive-menu entry has no guard at all. It also states plainly that there is no plugin loader and no
|
|
1162
|
+
public import surface, why that is deliberate (an in-process plugin would hold the same reach as the code it joins -
|
|
1163
|
+
database path, every API key, the message channel - against a standing rule that those boundaries stay explicit), and
|
|
1164
|
+
what a third party can use today instead (MCP, which is enumerable, scoped per tool, and switchable off).
|
|
1165
|
+
|
|
1166
|
+
### Changed
|
|
1167
|
+
|
|
1168
|
+
- **Drafting no longer stops when the judgement model is unreachable.** Jev's free period ended on 2026-09-25, and
|
|
1169
|
+
the first failure it produced here was already visible to a user as a bare "the tool errored (script exit code 1)"
|
|
1170
|
+
- with the assistant then *inventing* a reason for it ("the model was inconsistent about whether to promise
|
|
1171
|
+
something"), because the tool only reported the exit code. The judgement step is now degradable: when that call
|
|
1172
|
+
fails, drafting continues on DeepSeek alone and returns the candidates **with `judged: false`** plus the reason, so
|
|
1173
|
+
the CLI prints "这次没有判断…闸门没生效" before the list and the assistant tool prints the same. That warning is the
|
|
1174
|
+
point: the gate (money, or risk >= 7) exists only because a judgement exists, so a degraded run must not look like
|
|
1175
|
+
a normal one - and it must not look like one *to the model relaying it* either. Availability follows the same
|
|
1176
|
+
logic: `deepseekApiKey` is now the only required key, and a missing `typesafeApiKey` no longer hides the tool.
|
|
1177
|
+
Verified end to end with a deliberately bad Jev key: the candidates come back, the warning comes back, and the
|
|
1178
|
+
tools never send anything.
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
- **Dragging the ball in the chat window grew the window by the drag distance, and the extra width showed up as
|
|
1182
|
+
a gap between the ball and the bubble.** `panel:dragMove` re-read the window's *current* size with
|
|
1183
|
+
`getContentBounds()` and wrote it straight back with the new position - a read-modify-write, and the read is
|
|
1184
|
+
the one that lags. Measured with a probe driving the same IPC sequence (30 steps, a 30x24 move) on a window
|
|
1185
|
+
with the panel's own options: with `resizable: false` the size does not move at all, and with
|
|
1186
|
+
`resizable: true` (which is what the chat window is) it drifts by **exactly the drag delta, 30x24**. The size
|
|
1187
|
+
is now captured once in `dragStart` and reused for every move, which measures 0x0 in both cases. The symptom
|
|
1188
|
+
matched: the bubble is pinned to the window's left and the ball to its right, so any width beyond
|
|
1189
|
+
"bubble + gap + ball" appears between them - and expanding or collapsing re-asserts the exact size, which is
|
|
1190
|
+
why clicking the ball "reset" it. A test pins that `dragMove` never calls `getContentBounds()`.
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
- **The MCP surface now says what it actually is, and `draft_reply` on that surface needs an explicit
|
|
1194
|
+
confirmation.** Two claims were wrong and are corrected here rather than dropped: `capabilities --json`
|
|
1195
|
+
reported `safety.mcpDefaultReadOnly: true`, and the MCP guide said the default surface is read-only. The
|
|
1196
|
+
surface is **derived from the assistant tool table minus `save_memory`**, so it has contained a file writer
|
|
1197
|
+
(`export_chat`) and a model-calling tool (`look_at_image`) since those shipped. `capabilities` now reports
|
|
1198
|
+
`mcpDefaultReadOnly: false` plus a `safety.mcpSurface` breakdown (`writesFiles`, `callsCloudModels`,
|
|
1199
|
+
`requiresConfirm`), and the guide's tool table lists the whole surface instead of the eleven hand-written
|
|
1200
|
+
entries. Auditing that surface turned up one more thing: it lists **four** tools that send user data to cloud
|
|
1201
|
+
models (`who_owes_reply`, `search_chats`, `search_semantic`, `draft_reply`), not the two the first version of
|
|
1202
|
+
that declaration guessed, and `look_at_image` is now **excluded from the surface entirely** - it hands its
|
|
1203
|
+
image to the assistant's own model through a side channel MCP never reads, so over MCP it answered "you can
|
|
1204
|
+
see it now" while shipping nothing. The two tests whose names said "read-only tool surface" but only asserted that publishing, sending
|
|
1205
|
+
and memory writes are absent are renamed to say what they check.
|
|
1206
|
+
|
|
1207
|
+
Because drafting sends a conversation to two cloud models, an **MCP call without `confirm: true` returns a
|
|
1208
|
+
preview only** - how many messages, how many characters, which two models - and nothing leaves the machine.
|
|
1209
|
+
The panel and the WeChat bot keep working without that flag: their boundary is the sender allowlist plus a
|
|
1210
|
+
user asking in a conversation only they can see, whereas an MCP client is a third-party process whose
|
|
1211
|
+
session nobody here can observe. `capabilities.safety.mcpSurface.requiresConfirm` declares it, the tool
|
|
1212
|
+
description states it (a calling model that does not know it cannot ask its user), and a new test drives the
|
|
1213
|
+
real MCP protocol to prove that a call without the flag never produces a draft. The same gate now covers
|
|
1214
|
+
the other three tools that send user data to cloud models (`who_owes_reply`, `search_chats`,
|
|
1215
|
+
`search_semantic`) through one shared message shape - each tool fills in what it actually sends, and the
|
|
1216
|
+
two whose scripts support `--dry-run` show real counts.
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
- **The ball no longer disappears when you open it: the conversation unfolds beside it, like an icon
|
|
1220
|
+
that is talking.** The window is now "ball + gap + bubble" as one piece (508x560), with the ball
|
|
1221
|
+
pinned to its own corner and the bubble growing out of the other side. The ball is the speaker, so it
|
|
1222
|
+
stays put - it is a **toggle** (click to open, click again to close), and the geometry is computed by
|
|
1223
|
+
`bubbleLayout` in `ball-position.cjs`, a pure function CI drives with synthetic displays. It prefers
|
|
1224
|
+
the bubble on the ball's left, bottom-aligned (the ball defaults to the bottom-right, so the bubble
|
|
1225
|
+
grows up-left without covering anything), flips to the right when there is no room on the left, and
|
|
1226
|
+
switches to top-alignment when the bubble would run off the top of the work area - a popover, in
|
|
1227
|
+
short. A 12px CSS tail points at the ball's centre. The mode still has **one source of truth** (the
|
|
1228
|
+
main process announces `{mode, side, anchorY}`; the page only requests changes), so the ball, the
|
|
1229
|
+
collapse button, the tray and the hotkey all take the same path, and `prefers-reduced-motion` still
|
|
1230
|
+
stops both halves. The page's layout moved into a new `#bubble` wrapper element, which is what carries
|
|
1231
|
+
the rounded corners and the background - the window's background is transparent now in both modes, so
|
|
1232
|
+
the gap beside the ball really shows the desktop.
|
|
1233
|
+
|
|
1234
|
+
Measured on a real Electron window with the real page (throwaway probe, 150% display scaling), because
|
|
1235
|
+
two of the three things below were **not** visible by reading the code:
|
|
1236
|
+
|
|
1237
|
+
- **The ball does not move.** Its offset inside the window is exactly 0 at every sample, the window's
|
|
1238
|
+
anchored corner is held constant, and sampling the ball's screen position through a whole expand and
|
|
1239
|
+
a whole collapse gives a spread of **at most 1px** (page-side, 23 and 30 samples). At rest before and
|
|
1240
|
+
after, the ball's screen position is identical (1608.33 both times; the 0.33 is the 150% scale).
|
|
1241
|
+
- **Two bugs found by measuring.** (1) The tween rounded each of the four edges independently, so the
|
|
1242
|
+
anchored edge wobbled by 1-2px (measured right-edge values 1683/1684/1685) - and since the ball is
|
|
1243
|
+
CSS-fixed to that edge, that wobble *is* the ball moving. `boundsAt` now takes the corner to pin and
|
|
1244
|
+
derives the coordinates from that edge, so it is exact. (2) Collapsing placed the ball at the
|
|
1245
|
+
**window's top-left** instead of the corner it actually sits on, which would have made it jump to the
|
|
1246
|
+
bubble's opposite corner; `ballRectInWindow` (also pure, now tested as a round-trip: for all four
|
|
1247
|
+
corners, expand-then-collapse returns the ball to where it started) fixes it.
|
|
1248
|
+
- **A platform wrinkle worth knowing**: asking this window for 76x76 at 150% scaling yields a ~79px
|
|
1249
|
+
client width - Windows enforces a minimum window width. Everything stays consistent because the ball
|
|
1250
|
+
is anchored to the edge rather than to a computed size, but the window is a few px wider than the
|
|
1251
|
+
ball, and the remembered position (`~/.weflow-cli/panel_position.json`) is the window's, not the
|
|
1252
|
+
ball's visual left edge.
|
|
1253
|
+
|
|
1254
|
+
Frames land every 28-45ms (3-8 frames for a 190ms tween across runs), because the cost is the actual
|
|
1255
|
+
window resize, not the 16ms timer; the motion is time-based, so it drops frames rather than stretching.
|
|
1256
|
+
`BALL_SIZE` and the new `BUBBLE_SIZE`/`BUBBLE_GAP` live in `ball-position.cjs` only, with tests pinning
|
|
1257
|
+
the CSS copies (`--ball-size`, `--bubble-width`, `--bubble-gap`) to them. How it *looks* is still a
|
|
1258
|
+
human judgement - a screenshot cannot settle it, because GDI capture misses DWM-composited transparent
|
|
1259
|
+
windows.
|
|
1260
|
+
|
|
1261
|
+
### Fixed
|
|
1262
|
+
|
|
1263
|
+
- **91% of the article notes had WeChat's own reader UI text inside their summary.** The article body comes from
|
|
1264
|
+
WeChat, so it carries the reader's widget text and the footer's 原创 marker with the account name pasted three
|
|
1265
|
+
times - and the summary is built from the article's first lines, so it went straight in. Measured over the corpus:
|
|
1266
|
+
**1484 of 1633** notes are affected (an earlier count of 330 in this work was wrong - it counted one exact phrase
|
|
1267
|
+
over whole files, and the residue has several spellings). The cleaner now runs where notes are written and where
|
|
1268
|
+
knowledge cards are built, and re-running it over the corpus leaves **0** affected.
|
|
1269
|
+
|
|
1270
|
+
Two things about it are worth recording. The pattern list that already existed in `classify_daily.py` was used only
|
|
1271
|
+
on the daily-report path - the note writer never called it - and comparing it against the real string showed it
|
|
1272
|
+
**missed one of the three reader phrases**: the actual text is 在**公众号**小说中沉浸阅读 while the list had
|
|
1273
|
+
在小说阅读器**中**沉浸阅读. The list now covers the observed forms, lives in `_utils.strip_wx_ads` (one
|
|
1274
|
+
implementation; `classify_daily.clean_ads` delegates to it and keeps only its daily-specific rules), and strips the
|
|
1275
|
+
underscore runs and the repeated account name as well. It is deliberately narrow: a test asserts that a sentence
|
|
1276
|
+
containing 沉浸, an underscore in a variable name and the word 原创性 comes back untouched.
|
|
1277
|
+
|
|
1278
|
+
The **existing** vault notes still carry the residue - the cleaner applies to what gets written, not to what is
|
|
1279
|
+
already there - but the knowledge path cleans at the card step, so it does not reach concept pages. Rewriting the
|
|
1280
|
+
1484 existing summaries is a separate, approval-shaped action and has not been done.
|
|
1281
|
+
|
|
1282
|
+
- **The wiki aggregator was reading the wrong fields and treating non-concepts as concepts, and scanning the whole
|
|
1283
|
+
corpus showed why the article line produces nothing.** This was found by running the aggregator over the real Vault
|
|
1284
|
+
rather than over fixtures - 1633 notes, read-only - which turned up four things at once:
|
|
1285
|
+
|
|
1286
|
+
- the notes' theme lives in `hasTopic: [[AI]]` (because the vault's own dataview queries use that field), while the
|
|
1287
|
+
aggregator read `topic`, so **every note's theme was silently empty**. It now reads both and strips the brackets;
|
|
1288
|
+
the writer was deliberately *not* changed, since renaming the field would break the user's Obsidian queries;
|
|
1289
|
+
- the summary section of those notes is headed `## 📋 摘要`, which the aggregator did not recognise - it was landing
|
|
1290
|
+
on the same paragraph only because the "first paragraph" fallback happened to reach it. That heading is now
|
|
1291
|
+
explicit, with the fallback kept for producers that write no heading;
|
|
1292
|
+
- **the body's `[[...]]` links are not concepts**: 1631 of 1633 notes have exactly one body link and it is their own
|
|
1293
|
+
theme (`> - **主题**: [[AI]]`), and the `## 🔗 关联网络` sections link **note paths** (`[[AI/某篇.md]]`). Taken at
|
|
1294
|
+
face value those produced a concept ranking led by "新闻 (752), 政治 (388), 学术 (341)" plus two `.md` file paths -
|
|
1295
|
+
a `--limit 20` run would have spent twenty model calls writing that into the Vault. Relation sections are now
|
|
1296
|
+
excluded by structure and path-shaped links by form;
|
|
1297
|
+
- and the conclusion: **after filtering, the article corpus yields zero concepts**, because these notes have no
|
|
1298
|
+
concept section at all. `wiki compile` cannot produce concept pages from articles until something extracts
|
|
1299
|
+
concepts from them - the same gap the conversation line closed on 2026-09-26 with `chat-notes`, which is the only
|
|
1300
|
+
source that produces them today. `--source`'s help text and `OPERATIONS.md` now state the requirement instead of
|
|
1301
|
+
pointing at a directory that cannot satisfy it.
|
|
1302
|
+
|
|
1303
|
+
The theme is also *used* now, not just read: it appears in the reference lines handed to page generation and as
|
|
1304
|
+
`topics` in a concept page's frontmatter (`topic` had been collected and dropped since the aggregator was written).
|
|
1305
|
+
Eleven tests cover it, six of them new here, including one that pins the measured corpus shape and one that asserts
|
|
1306
|
+
a relation section yields no concepts *whatever* is written in it. Mutation checks caught two of my own tests being
|
|
1307
|
+
unable to distinguish the two filters - each filter alone could be deleted without a failure, because the fixture
|
|
1308
|
+
only exercised them together - so both now have a case of their own.
|
|
1309
|
+
|
|
1310
|
+
- **The WeChat channel could die silently, and one of the three reasons was a wrong error code.** Checked
|
|
1311
|
+
against the vendor's own implementation (`third-party/WeKnora/internal/im/wechat/longpoll.go:151`), which
|
|
1312
|
+
encodes what this side had been guessing at: **`errcode: -14` is "this token is no longer valid"**, while this
|
|
1313
|
+
repository only ever looked at `-1 || 401`. A wrong code does not raise anything - it quietly reclassifies
|
|
1314
|
+
"expired" as an ordinary error, so the loop retried every 5 seconds, forever, while the user saw nothing.
|
|
1315
|
+
Three things changed, and the third is the one that matters most:
|
|
1316
|
+
|
|
1317
|
+
- `-14` (and HTTP 401) are now recognised, and treated differently from a network blip, because retrying an
|
|
1318
|
+
expired token cannot succeed;
|
|
1319
|
+
- retries use 1s→30s exponential backoff instead of a fixed 5 seconds, so a long outage no longer floods the
|
|
1320
|
+
log - noise is the same as saying nothing;
|
|
1321
|
+
- **the failure is now stated where a user will see it**: one unmissable log line naming the remedy, and
|
|
1322
|
+
`isChannelActive()` - which the panel shows - reports `false`, because it used to mean "a token was
|
|
1323
|
+
configured at startup" and stayed true forever after the token died. The panel now reads "仅本机入口",
|
|
1324
|
+
which is the truth, and the local entrance keeps working either way.
|
|
1325
|
+
|
|
1326
|
+
Also: the message callback was wrapped in `try { cb(msg) } catch {}`, so **any exception raised while the
|
|
1327
|
+
assistant handled a message vanished** - the user sent something, nothing happened, and the log had no trace
|
|
1328
|
+
of either. It is logged now. A fourth item from the same 2026-09-17 comparison turns out **not** to have been
|
|
1329
|
+
a defect and is retracted: this side uses a 40 s HTTP timeout for the long poll, and so does the vendor
|
|
1330
|
+
(`longPollHTTPTimeout = 40 * time.Second`, in the same file) - the "we might abort on the boundary" worry in
|
|
1331
|
+
that note does not hold.
|
|
1332
|
+
|
|
1333
|
+
Five tests were added for the parts that can be tested without a live channel: the classification table, the
|
|
1334
|
+
backoff curve, and a driven loop (a stubbed client reporting `-14` once and then a message whose callback
|
|
1335
|
+
throws) asserting that the expiry is reported **once** rather than every round, that the callback's exception
|
|
1336
|
+
is logged, and that the state recovers after a healthy poll. Four mutations - reverting the error code, the
|
|
1337
|
+
backoff, the channel-status semantics, and the swallowing - each turn one red. The live path (a real expired
|
|
1338
|
+
token) has not been exercised: it needs the server to say `-14`, which cannot be forced from here.
|
|
1339
|
+
|
|
1340
|
+
- **`draft`'s interactive confirmation was broken: answering "yes" ran nothing.** The command built the
|
|
1341
|
+
script's arguments *before* asking, so `--yes` was only ever present when it was passed on the command line;
|
|
1342
|
+
the interactive answer never became that flag, and the script - which has its own `--yes` gate - refused the
|
|
1343
|
+
run. What the user saw was "I confirmed, and it told me I had not". The two ways of saying yes are now one
|
|
1344
|
+
variable and the arguments are built after the decision, so the flag cannot be missing on one path while
|
|
1345
|
+
present on the other. The interactive path itself has no test (it needs a terminal), which is exactly why the
|
|
1346
|
+
fix is structural: the shape that could forget the flag no longer exists.
|
|
1347
|
+
|
|
1348
|
+
- **The transcript line was implemented twice, and the two copies had drifted in four ways.** Nothing failed - the two
|
|
1349
|
+
paths just fed different text into the same judgement and drafting prompts. The tool path
|
|
1350
|
+
(`assistantTools.transcriptLine`, panel and WeChat: mask, then send over `--stdin`) and the CLI path
|
|
1351
|
+
(`reply_debt.format_line`, used by `draft_reply.py --talker`, which reads the database itself) rendered the same
|
|
1352
|
+
message as `[9/23 12:20] 对方:…` and `[09-23 12:20] 老王:…`, truncated at 160 characters against 120, and marked the
|
|
1353
|
+
cut with `…` on one side only. The most dangerous of the four is the `我` marker: the scripts pick the user's tone
|
|
1354
|
+
sample by matching `'] 我:'` and decide who sent the last message from it, so one character of drift would disable
|
|
1355
|
+
both silently. The shape is now a single contract, enforced by `test/transcript-format-contract.test.ts`, which
|
|
1356
|
+
renders the same fixtures - self, other, non-text, over-long, a third person in a group - through both
|
|
1357
|
+
implementations and requires them to be equal character for character. Seven mutations, each reverting one
|
|
1358
|
+
difference or breaking one side's clip marker or clip length, turn it red.
|
|
1359
|
+
|
|
1360
|
+
Two things surfaced while measuring, and both changed the fix. The clip-length comparison had to take its value from
|
|
1361
|
+
the Python constant rather than the TypeScript one - feeding Python the TypeScript number made "both sides changed to
|
|
1362
|
+
120" compare equal, so the first version of that test could not see its own subject. And `senderUsername` turned out
|
|
1363
|
+
to be **the raw wxid column** (`wcdbCore`'s `sender_username`, `sqlcipherCore`'s `StrTalker`), not a name, so using
|
|
1364
|
+
it as a speaker label sent an account identifier to two cloud models while the transcript only needed to tell
|
|
1365
|
+
speakers apart. Both implementations now use a resolved display name when there is one and `对方` otherwise, and
|
|
1366
|
+
neither falls back to the raw column.
|
|
1367
|
+
|
|
1368
|
+
The same contract also covers **how much conversation each path sends**: 30 messages (`TRANSCRIPT_MESSAGES` in
|
|
1369
|
+
TypeScript, `TRANSCRIPT_SIZE` in Python) alongside the 160-character limit per message. Those two numbers agree
|
|
1370
|
+
today, but they agreed by coincidence - neither was watched, and either one changing alone would have made the same
|
|
1371
|
+
conversation a different input depending on which entrance it came through. Two mutations, one per side, each turn
|
|
1372
|
+
the assertion red.
|
|
1373
|
+
|
|
1374
|
+
- **`get_messages` labelled speakers with a raw wxid, and that text goes to a cloud model.** The tool's output is a
|
|
1375
|
+
transcript the assistant reads, and every line from the other side was tagged with the value of `senderUsername` -
|
|
1376
|
+
which is a database column holding the sender's **wxid**, not a name. So asking "what did 老王 send me" produced
|
|
1377
|
+
lines tagged with an opaque account identifier: an identifier leaving the machine across a boundary the project
|
|
1378
|
+
treats as sensitive, in exchange for information the model cannot use. It is fixed by the same rule the drafting
|
|
1379
|
+
transcript now follows - a resolved display name when there is one, `对方` otherwise - and by one shared helper
|
|
1380
|
+
(`speakerLabel`), since "who is speaking" is exactly the kind of thing that grows a second implementation. This was
|
|
1381
|
+
measured before it was changed, and the measurement is the reason it *could* be changed: across 8 sessions and 179
|
|
1382
|
+
messages from other people, `senderDisplay` held a name 174 times, `senderUsername` was guid-shaped 176 times, and
|
|
1383
|
+
the two were never the same value - so using the name keeps a group's speakers distinguishable rather than
|
|
1384
|
+
collapsing them. The user's own lines still read `用户` there and `我` in the drafting transcript; those are
|
|
1385
|
+
different framings on purpose and each is pinned by a test.
|
|
1386
|
+
|
|
1387
|
+
- **The assistant was allowed to invent a reason for a tool failure, and did.** When drafting failed on 2026-09-25 the
|
|
1388
|
+
tool reported only "the judgement step failed (script exit code 1)" and the assistant answered with "the reason is
|
|
1389
|
+
probably that the model was inconsistent about whether to promise something" - a cause it had no evidence for. The
|
|
1390
|
+
real cause was a dropped connection while calling the judgement model, which the tool *did* know about; what the
|
|
1391
|
+
prompt lacked was a rule. It now has one: when a tool fails (its result starts with a bracket), relay only the reason
|
|
1392
|
+
the tool gave, and if it gave none - or only an exit code - say that, plus the next step. Guessing is named
|
|
1393
|
+
explicitly, because the user reads the guess as a fact. The assertion reads the system prompt **as actually sent to
|
|
1394
|
+
the model**, not the source string.
|
|
1395
|
+
|
|
1396
|
+
- **Drafting asked "what is their last message about?" even when the last message was mine.** The judgement questions
|
|
1397
|
+
are written around *their* last message, and the drafting prompt is told to reply to what they said - but nothing
|
|
1398
|
+
checked who sent the last line. If the user had sent it, the judgement interpreted **the user's own words** as the
|
|
1399
|
+
other person's position (intent, need and "what should I do" all answered against the wrong person), and those
|
|
1400
|
+
answers then went into the drafting prompt as context. Both prompts now receive the premise when it holds
|
|
1401
|
+
("the last message is mine, they have not replied - do not read my words as their position"), and the premise is
|
|
1402
|
+
printed to the user ahead of the candidates, because otherwise the candidates look like answers to a message that
|
|
1403
|
+
does not exist. The fact is carried as a structured `isSend` field, never parsed back out of the transcript text -
|
|
1404
|
+
in a group chat the other side's label is a person's name, not `对方`. When the caller cannot say who sent it, no
|
|
1405
|
+
premise is produced at all: a guessed premise would be worse than none.
|
|
1406
|
+
|
|
1407
|
+
- **Nothing kept the tool table and its four consumers in agreement - now something does.** `TOOL_DEFS` (19 tools),
|
|
1408
|
+
the `executeTool` switch, the availability rules, the MCP exclusion table and the MCP documentation table were five
|
|
1409
|
+
hand-maintained lists with no assertion between them. The way that fails is silent in both directions: declare a tool
|
|
1410
|
+
without a `case` and the model sees it, calls it, and gets `(未知工具: x)`; add a `case` without a declaration and it
|
|
1411
|
+
is dead code no model can reach. `test/tool-registry.test.ts` now derives the set from the source and compares all
|
|
1412
|
+
five - both directions, names unique, every declaration usable, every name in the two lookup tables real, and the
|
|
1413
|
+
`docs/MCP.md` table equal to the actually-served surface. It found two live defects on its first run: the availability
|
|
1414
|
+
rules and the MCP exclusion table became **declarative one-line entries** (`TOOL_REQUIREMENTS`, `MCP_EXCLUDED`, the
|
|
1415
|
+
value being the reason) instead of name checks buried in if-chains, and **`wechat.export_messages` was missing from
|
|
1416
|
+
the `docs/MCP.md` table** - a tool that is served but was not documented, i.e. the same direction of drift as the
|
|
1417
|
+
duplicate row below. Five mutations (rename in the table, rename a case, a bogus exclusion name, a duplicated doc
|
|
1418
|
+
row, a renamed served tool) each turn a specific assertion red.
|
|
1419
|
+
|
|
1420
|
+
- **`capabilities --json` was under-reporting which MCP tools need `confirm: true`.** A machine-readable safety
|
|
1421
|
+
field that under-reports is the same class of problem as one that lies. `safety.mcpSurface.requiresConfirm`
|
|
1422
|
+
listed one tool (`draft_reply`) while **four** handlers carry the identical gate
|
|
1423
|
+
(`if (ctx.requiresConfirm && args.confirm !== true)` - `search_chats`, `who_owes_reply`, `draft_reply`,
|
|
1424
|
+
`search_semantic`). `docs/MCP.md` had been saying "all four" correctly all along; the two things that disagreed
|
|
1425
|
+
were the field and its test, because the assertion had been written by copying the field's value rather than by
|
|
1426
|
+
reading the code - so the pair stayed consistent with each other and wrong together. The assertion now **derives**
|
|
1427
|
+
the set from the source (every `case` block that contains the gate) and compares it against both `requiresConfirm`
|
|
1428
|
+
and `callsCloudModels`, so changing one without the other turns a test red; a mutation that restores the old
|
|
1429
|
+
single-name list fails it. The same audit turned up a **duplicate row** in the `docs/MCP.md` tool table:
|
|
1430
|
+
`wechat.who_owes_reply` appeared twice, and the stale copy was the one that did not say chat text leaves the
|
|
1431
|
+
machine. Deleted.
|
|
1432
|
+
|
|
1433
|
+
- **Clicking the ball did nothing.** The ball carried `-webkit-app-region: drag` so it could be dragged -
|
|
1434
|
+
and on Windows a drag region **swallows mouse events**, so the page never received the click. Dragging is
|
|
1435
|
+
now implemented in the page itself (pointer events, with a 4-pixel threshold that separates a click from
|
|
1436
|
+
a drag) and the native drag region is gone. The verification is the part worth recording: this had been
|
|
1437
|
+
"verified" earlier by calling `element.click()` through the DevTools protocol, which **bypasses real
|
|
1438
|
+
input** and happily reported a ball that could not be clicked. Both the new click and the drag are now
|
|
1439
|
+
confirmed with synthetic-but-real mouse input (`SetCursorPos` + `mouse_event`): 78x76 -> 421x560 -> 77x76,
|
|
1440
|
+
and a drag that moves the window without changing its size.
|
|
1441
|
+
|
|
1442
|
+
- **Dragging made the ball grow.** `win.setPosition` / `win.setBounds` on this window (frameless,
|
|
1443
|
+
transparent, non-resizable) operate on the **outer** rect and drift a little on every call - measured at
|
|
1444
|
+
roughly +0.8px per call, with no bound: 20 moves took 76x76 to 97x92, and the same happens without any
|
|
1445
|
+
mouse involved (calling the drag IPC directly reproduces it). `setContentBounds` (the client area) is
|
|
1446
|
+
stable - 20 moves, size unchanged. **That measurement was narrower than the sentence:** it was taken on the
|
|
1447
|
+
non-resizable ball window, and the claim does not survive on a *resizable* one if the size is re-read from
|
|
1448
|
+
`getContentBounds()` on every move - see the drag fix below. The same drift was quietly affecting `setMode` too: expanding measured
|
|
1449
|
+
421x561 rather than the requested 420x560.
|
|
1450
|
+
|
|
1451
|
+
Every "atomic write" in this project (the memory file,
|
|
1452
|
+
the configuration, the panel's endpoint file) wrote a `.tmp` and renamed it over the target - and on
|
|
1453
|
+
Windows `rename` fails with `EPERM` when another handle has the target open, which antivirus and search
|
|
1454
|
+
indexers do briefly and routinely. The callers all record a reason and carry on, so the visible effect
|
|
1455
|
+
was nothing at all: one full-suite run here saved the memory file and the file came back without its
|
|
1456
|
+
`version` field, with the test green on the other three runs. The write now retries a busy target and
|
|
1457
|
+
then falls back to writing in place (D-046) - the bytes are the same either way, so only the atomicity of
|
|
1458
|
+
that single write is given up, and the test that reproduces it is in the suite.
|
|
1459
|
+
|
|
1460
|
+
- **"No pending todos" and "todos were never extracted" were the same sentence.** Todo extraction reads
|
|
1461
|
+
chat logs, so it runs only when the user invokes `weflow-cli todos extract --days N --yes` - it is a
|
|
1462
|
+
confirmed action, and nothing schedules it. On a machine where that has never been run, the todo file
|
|
1463
|
+
does not exist, `list` returns an empty array, and both the assistant tool and the terminal printed
|
|
1464
|
+
"nothing to do". Those are different claims: one is about the user's workload, the other about whether
|
|
1465
|
+
the question was ever asked. This was not hypothetical - on this machine `~/.weflow-cli/todos.json`
|
|
1466
|
+
does not exist, so `get_todos` was answering `(没有待办任务)` to every question about pending work.
|
|
1467
|
+
The script now reports whether the file exists (`list --json --meta` gives `{items, extracted,
|
|
1468
|
+
count}`), the assistant names the missing step and the command that fixes it, and `todos list` /
|
|
1469
|
+
`todos remind` say it too instead of congratulating an empty list.
|
|
1470
|
+
|
|
1471
|
+
The bare-array shape of `list --json` is deliberately unchanged - it is a published capability - so
|
|
1472
|
+
the new signal rides on a flag rather than a changed contract; the tool also falls back to the old
|
|
1473
|
+
wording if it gets an array. `mcp_bridge.py` had always made this distinction; the assistant tool was
|
|
1474
|
+
the one reader that dropped it.
|
|
1475
|
+
|
|
1476
|
+
- **The chat export tool reported a directory it may not have written to.** `export_chat` builds the
|
|
1477
|
+
destination as `output/exports/<name>-<timestamp>`, and on a name collision appends `-2`. The reply
|
|
1478
|
+
to the user was assembled from the *pre-collision* name, so the second export in the same second told
|
|
1479
|
+
them to look in `<name>-<timestamp>` while the files were in `...-2`. A write tool that names the wrong
|
|
1480
|
+
location is worse than one that names none: the user goes looking and finds nothing. The message is now
|
|
1481
|
+
built from the directory that was actually written; with the default root it stays a repository-relative
|
|
1482
|
+
path (`output/exports/...`, so it is followable), and with an overridden root it is the bare directory
|
|
1483
|
+
name - an absolute local path has no reason to enter the conversation.
|
|
1484
|
+
|
|
1485
|
+
Found by pointing the tool at a real database with the export root redirected to a temporary directory.
|
|
1486
|
+
The test that should have caught it had pinned the bug: it redirected the root *and* asserted the
|
|
1487
|
+
message contained `output/exports/`, so it was written to match the code rather than the intent. It is
|
|
1488
|
+
now two cases - overridden root reports the bare name, default root reports the relative path - and
|
|
1489
|
+
both assert no drive letter appears.
|
|
1490
|
+
|
|
1491
|
+
- **A failed memory save was silent.** `AssistantMemory.save()` ended in `catch { /* persistence failure
|
|
1492
|
+
must not break the conversation */ }` - the right *behaviour* (a disk hiccup should not drop the
|
|
1493
|
+
reply) with the wrong *silence*: the user says "remember this", the write fails, the memory is gone,
|
|
1494
|
+
and nothing anywhere records it. Found by accident: one flaky full-suite run failed two "memory
|
|
1495
|
+
survives a restart" tests and no line anywhere pointed at why. A failed save now records the error
|
|
1496
|
+
code in the audit (`MEMORY_SAVE_FAILED code=…`, never the message, which can hold a path), exposes
|
|
1497
|
+
the reason as `memory.lastSaveError`, and the in-chat `记忆` command prints it. That last one matters
|
|
1498
|
+
most: a user asking what the assistant remembers must not be shown an account of a memory that never
|
|
1499
|
+
reached the disk. The in-memory state was never at risk - a failed save leaves the dirty set intact,
|
|
1500
|
+
so the next save retries.
|
|
1501
|
+
|
|
1502
|
+
- The test for it needed its own file, and its first two versions were wrong in ways worth recording.
|
|
1503
|
+
The failure has to be manufactured **before** `assistantMemory` is imported (module-level paths are
|
|
1504
|
+
computed at import), so it cannot live beside the other memory tests. And the first fixture put a
|
|
1505
|
+
directory where the memory file goes, which does not work: `load()` reads a directory, throws
|
|
1506
|
+
`EISDIR` and takes its **quarantine** path, renaming the directory away - so by the time `save()` runs
|
|
1507
|
+
the name is free and the save succeeds. The failure being tested for did not exist, and the test
|
|
1508
|
+
reported "the reason was not recorded". The working fixture occupies the `.tmp` name instead, which
|
|
1509
|
+
fails the write while leaving the audit file writable.
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
- **The labelling sheet could not actually be labelled.** `sample` printed the first 400 characters of
|
|
1513
|
+
the article body, and that region is boilerplate: `# title`, `> source / > time / > 阅读原文`, then the
|
|
1514
|
+
scraped copy of the article, which repeats the title and carries the cleanup leftovers. On the sample
|
|
1515
|
+
that produced it, most rows showed a title and a source name and nothing else - found by actually
|
|
1516
|
+
labelling a batch, where the only thing left to judge from was the title. The excerpt now takes the
|
|
1517
|
+
**`## AI 摘要` section**, the two or three sentences the judgement is really about; without a summary it
|
|
1518
|
+
falls back to the body after `## 正文`, then to the first non-boilerplate paragraph. Cleanup leftovers
|
|
1519
|
+
are matched by their invariant fragments (`在小说阅读器`, `沉浸阅读`) rather than by whole sentences,
|
|
1520
|
+
because the wording varies between articles.
|
|
1521
|
+
|
|
1522
|
+
- **`--seed` did not actually reproduce a sample**, despite the help text promising "两次抽样结果一致".
|
|
1523
|
+
Two causes, found one after the other: the pool was drawn in **concurrent completion order**
|
|
1524
|
+
(`as_completed`), and `rng.shuffle` consumes that order, so the same seed produced a different 50 (7 of
|
|
1525
|
+
50 rows differed between two consecutive runs); and even with the order fixed, **the scores come from a
|
|
1526
|
+
live model**, so an article scoring 0.49 in one run and 0.52 in the next changes band and therefore
|
|
1527
|
+
changes the draw. The pool is now sorted before grouping and scores are **cached by article path**
|
|
1528
|
+
(`~/.weflow-cli/labels/.jev-scores.json`), so two runs of the same command produce the same 50 in the
|
|
1529
|
+
same order - verified, and pinned by a test that feeds the same items in reverse order. The cache also
|
|
1530
|
+
means a re-run costs no quota; `--refresh` bypasses it when the model or the criteria change.
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
- **Quoted messages no longer lose what they were quoting.** In the appmsg payload the *reply* sits in
|
|
1534
|
+
`title` and the *quoted original* in `refermsg/content`, and only the first was read. Measured on 37
|
|
1535
|
+
real quote messages in one archive: the quoted original is a median of 36 characters (longest 12733 -
|
|
1536
|
+
a whole article was pasted in), so the model was routinely shown a line like "是呀,够得意个" with no
|
|
1537
|
+
way to know what it was replying to. Both parts are carried now, separated by ` | 引:`. Each is
|
|
1538
|
+
clipped with a trailing `…` (reply 60, quoted text 120) because an unmarked cut reads as a complete
|
|
1539
|
+
sentence - the longest quoted text would otherwise have looked like it simply ended. Entities are
|
|
1540
|
+
decoded for display (`a&b` reads as `a&b`), which the WeChat 3.x reader already did and the 4.x
|
|
1541
|
+
one did not.
|
|
1542
|
+
|
|
1543
|
+
- **The same fix reached the WeChat 3.x reader, which had no test at all.** `sqlcipherCore`'s AppMsg
|
|
1544
|
+
formatting was the second implementation of this display form; it is now `core/appMsgFormat.ts` as
|
|
1545
|
+
pure functions with 13 tests. Extracting it immediately paid for itself: reading `<type>` from the
|
|
1546
|
+
whole document could pick up a quoted message's `<type>` instead of the outer message's, and the
|
|
1547
|
+
entity-decoding the original did was very nearly dropped in the move (the tests caught both). The two
|
|
1548
|
+
implementations now share one set of clip lengths and one separator, so the same message reads the
|
|
1549
|
+
same on either WeChat version - they are still two implementations, and the tests pin the values they
|
|
1550
|
+
must agree on.
|
|
1551
|
+
|
|
1552
|
+
- **One assistant message body was cut at 80 characters.** A quote (`[引用] reply | 引:quoted`) had
|
|
1553
|
+
its quoted text reduced to seven or eight characters - carried, but useless - and any long message
|
|
1554
|
+
was cut mid-sentence with nothing to show it had been cut. The limit is now 160, with a trailing `…`.
|
|
1555
|
+
Worst case stays bounded: 50 messages × ~180 characters ≈ 9k.
|
|
1556
|
+
|
|
1557
|
+
- **The Vault's copy of an article now has the WeChat interface phrases stripped; the file it was copied
|
|
1558
|
+
from does not.** `Sources/WeChat/` is the layer a person reads and searches, and "继续滑动看下一个",
|
|
1559
|
+
"轻触阅读原文" and the `在小说阅读器中沉浸阅读` family (eight phrases) are page furniture rather than
|
|
1560
|
+
article text - searching the vault used to surface them. `output/biz-daily/` keeps the fetched text
|
|
1561
|
+
byte-for-byte, because it is the only copy of the bodies and a cleaner with a bug there would destroy
|
|
1562
|
+
material that cannot be re-derived. Measured after the pass: 15,731 of 35,463 files carried a phrase
|
|
1563
|
+
before, **0 after**.
|
|
1564
|
+
|
|
1565
|
+
**The cleaning lives inside `copy_to_vault`, and that placement is the fix.** It is not a one-off: this
|
|
1566
|
+
step re-runs, so a cleaner sitting anywhere else gets undone by the next sync - which is exactly what
|
|
1567
|
+
happened during development, when a plain `--vault-sync` restored the residue on 15,731 files because an
|
|
1568
|
+
earlier pass had cleaned the Vault copies while the copy function was still a dumb `write_bytes`.
|
|
1569
|
+
|
|
1570
|
+
Two things are deliberately untouched. **The frontmatter**, because these are short words: `去阅读`
|
|
1571
|
+
occurs inside `如何去阅读一本书`, and the title is the only alignment key between a reading note and its
|
|
1572
|
+
source - measured, 0 of 35,060 files currently need it, so the guard is for the future and is written
|
|
1573
|
+
down as such rather than dressed up as a fix. **Fenced code blocks**, because `strip_wx_ads` also
|
|
1574
|
+
normalises whitespace, which is harmless for the summaries it was written for and eats indentation in
|
|
1575
|
+
code: 20 files carry fences and **10 of them would have been altered**.
|
|
1576
|
+
|
|
1577
|
+
**What it actually deletes, measured instead of assumed.** It is `strip_wx_ads`, the same function the
|
|
1578
|
+
notes already use, so it does more than the eight phrases: `______` runs, `javascript:void(0)` links,
|
|
1579
|
+
the `原创 <账号名>` byline that WeChat pastes several times over, and repetitions of the same token.
|
|
1580
|
+
The byline rule alone fires on **9,334 of 35,463 files**. It never rewords prose - sampled 300 articles
|
|
1581
|
+
and classified every diff - but this *is* text removal, and an earlier draft of this entry claimed the
|
|
1582
|
+
pass only touched phrases and whitespace, which measurement did not support. The byline is page
|
|
1583
|
+
furniture and the account name survives in `source:` in the frontmatter, so the trade is defensible;
|
|
1584
|
+
the original is in `output/biz-daily/` either way.
|
|
1585
|
+
|
|
1586
|
+
- **`--vault-sync` had been deciding which days to copy by a marker only one writer sets.** The gate was
|
|
1587
|
+
`backfilled` in `.articles.json`, but that key is written by the backfill path only - so the 12 days
|
|
1588
|
+
produced by the `daily` path were never copied **and were never reported as skipped**, including two days
|
|
1589
|
+
(08-24, 08-25) with no directory in the Vault at all. The gate is now "the day has at least one `.md`
|
|
1590
|
+
besides `README.md`", which is what the flag always meant: 187 → 199 days, 33,232 → 35,463 files. The
|
|
1591
|
+
1,755 files still carrying residue after the first cleaning pass were exactly this population. The
|
|
1592
|
+
reading notes were never affected - every one of them points at `output/biz-daily/...` through
|
|
1593
|
+
`local_source`, not at the Vault copy (verified across five months, 1,144 of 1,144), so `wiki lint`
|
|
1594
|
+
reports the same result before and after.
|
|
1595
|
+
|
|
1596
|
+
- **`wiki lint`'s "similar titles" report now says out loud that its own threshold has stopped
|
|
1597
|
+
discriminating.** The rule (a common substring of ≥5 characters covering ≥40% of the shorter title) was
|
|
1598
|
+
set when there were 5,062 pages with long Chinese news headlines. At 22,374 pages full of short names it
|
|
1599
|
+
fires on coincidences - `AI Agent开发框架` against `生物信息学LLM Agent综述` - and reports **424,697**
|
|
1600
|
+
groups. A 20-pair sample confirms the rule itself holds, so this is the threshold and not the
|
|
1601
|
+
implementation; the printed line now carries that caveat rather than letting the number read as a
|
|
1602
|
+
finding. The same investigation turned up a second, older problem: the rule **cannot catch the case it
|
|
1603
|
+
was written for** - `打虎!陈勇被查` and `中建集团副总经理陈勇被查` are the same event with different
|
|
1604
|
+
headlines, and their longest common substring is 4 characters, so only 2 of the 4 motivating titles ever
|
|
1605
|
+
matched. The misleading claim in the docstring is gone; tightening the threshold is left as a decision
|
|
1606
|
+
because every variant trades one kind of miss for another.
|
|
1607
|
+
|
|
1608
|
+
|
|
7
1609
|
## 1.7.0
|
|
8
1610
|
|
|
9
1611
|
### Added
|
|
10
1612
|
|
|
1613
|
+
- Article bodies are now cached as they are fetched, so a daily run can resume. The run used to be **all-or-nothing**: it fetches every article before writing anything, and the bodies only ever lived in memory, so any interruption discarded the whole day. On a 400-article day that is over an hour of fetching, and on 2026-09-22 it stopped at 105/403 having written nothing. Bodies are now stored under `output/.cache/fetch/<md5(url)>.md`: a re-run fetches only what is missing (a resumed article costs ~0s instead of ~20s, and it does **not** take the 8-12 s throttle sleep, because a cache hit sends no request), and switching between `--no-summary` and normal, or changing the classifier, no longer re-downloads the day. Only successes are cached - a failure would otherwise be remembered forever instead of retried. `--no-fetch-cache` forces a refetch.
|
|
1614
|
+
|
|
1615
|
+
- Topic exclusion: `dailyExcludeTopics` (comma-separated, e.g. `新闻,投资,学术`), settable with
|
|
1616
|
+
`weflow-cli config set dailyExcludeTopics "..."`, plus `--exclude-topics` on
|
|
1617
|
+
`generate_ai_report.py` / `generate_html.py` for a one-off override. This is a **display**
|
|
1618
|
+
switch: bodies are fetched and archived as usual, and the topic simply does not appear in the
|
|
1619
|
+
two views. Pre-fetch filtering was measured first and rejected - judging the type from a source
|
|
1620
|
+
name plus a title and digest agreed with the source-level configuration only 60% of the time on
|
|
1621
|
+
217 articles, with 48 false positives and no gold standard - and a skipped fetch is
|
|
1622
|
+
**irreversible**, since nothing gets archived. Excluding at the display layer also means
|
|
1623
|
+
changing your mind costs a regeneration, not a refetch. The exclusion is orthogonal to
|
|
1624
|
+
`--include-all` and to the inclusion score, it is applied identically by the report and the
|
|
1625
|
+
reader page, and all three lists in the report's **我拿不准的** section are filtered, so a
|
|
1626
|
+
report that excludes 新闻 cannot turn around and list 新闻 in its own uncertainty section.
|
|
1627
|
+
Excluding the focus topic is refused with a warning rather than obeyed: an empty subject makes
|
|
1628
|
+
the report either bodyless or exit with "未找到文章", which blames the wrong thing. A misspelled
|
|
1629
|
+
topic name is ignored **with a warning** - a silent no-op would look like the filter working.
|
|
1630
|
+
|
|
1631
|
+
- Source-level prior: `~/.weflow-cli/source_topics.json` accumulates how many articles each
|
|
1632
|
+
公众号 has been judged to publish per topic, from what the daily actually archived. A source
|
|
1633
|
+
with enough samples (`SOURCE_PRIOR_MIN_SAMPLES`) dominated by one topic (`SOURCE_PRIOR_SHARE`)
|
|
1634
|
+
is reported at the end of the run, and when that topic is one you exclude the run names it:
|
|
1635
|
+
`来源先验: 甲号 → 新闻(20 篇里 19 篇 = 95%)`. It **only reports - nothing is skipped**,
|
|
1636
|
+
because a pre-fetch skip is irreversible and this table is still growing. The table holds
|
|
1637
|
+
account names, so it lives outside the repository; counts are append-only and only articles
|
|
1638
|
+
that landed on disk are recorded, so the table and `output/` describe the same corpus.
|
|
1639
|
+
`stable_source_topic` returns `None` for "not known yet", and callers must not read that as a
|
|
1640
|
+
default topic. See D-037 and D-038.
|
|
1641
|
+
|
|
1642
|
+
- `test/config-keys.test.ts`: every key in `bin`'s `configurableKeys` must also be declared in
|
|
1643
|
+
the `CliConfig` interface, given a default, reset by `clear()` and read back by `load()`.
|
|
1644
|
+
These are the four ways a key can be accepted by `config set` and then quietly not survive a
|
|
1645
|
+
restart.
|
|
1646
|
+
|
|
1647
|
+
- `daily --no-summary`: judge without generating. Topic, relevance and the inclusion decision still come from the decision model, but **no LLM call is made at all** - no summaries, no tags, no concepts, no README briefing - so this run needs no DeepSeek key. Articles are still fetched and archived (the body is kept); the md simply carries no `## AI 摘要` section, and `.articles.json` records `summary: ""` rather than quietly substituting the platform's digest, because an empty heading reads as a failed generation and a digest reads as our summary - both claim something that is not there. Verified by running a 4-article day with a **deliberately invalid** DeepSeek key: it completes normally with no 401, i.e. nothing reached the LLM. Wired through `weflow-cli daily --no-summary` and `pipeline.py`; the downstream pipeline steps (action suggestions, wiki compile, AI report) still use an LLM, so a fully LLM-free run adds their `--skip-*` flags, and the pipeline says so when it notices.
|
|
1648
|
+
|
|
11
1649
|
- `sync run|status|verify`: a local message-sync checkpoint. `sync run` reads a time window, deduplicates against the previous checkpoint and records what it covered; `sync status` reports coverage without touching the database, so it still works while the database is locked. It does **not** advertise a stable cursor - overlapping windows plus local deduplication is what it offers, per D-027.
|
|
12
1650
|
- Per-shard read reporting. Shard open and read failures used to be swallowed by a bare `except: continue`, so "read 31 messages" and "read one shard and silently skipped three" were indistinguishable from the outside. `--report-shards` (opt-in; without it the JSON is byte-identical) surfaces `scanned/opened/failed` and one entry per shard. On the machine this was developed against the report reads `message_0.db` 31 rows and `message_3.db` 1258 - exactly the shape of the shard-read bug fixed in 1.6.4, which was completely invisible at the time.
|
|
13
1651
|
- A media coverage report on HTML export: `<prefix>_media.json` beside the parts, with a status and a reason for every media item (`embedded | cached | remote-fetched | missing | unsupported`). The exporter had been computing `COVER_STATE` counters and discarding them, and a media miss showed up only as a bare `[图片]` with no reason recorded. A real 120-message export reports 54 items: 35 embedded, 15 missing, 2 unsupported, 2 remote-fetched, with reasons `not-in-local-cache` and `voice-not-in-media-index`.
|
|
14
1652
|
- `docs/SYNC_CONTRACT.md` freezing the `weflow-sync/v1` and `weflow-job/v1` state schemas, and `D-029` recording the additive-only boundary, why the identity omits `shard`, and why `sync retry` is deliberately not implemented.
|
|
1653
|
+
- `python scripts/nt_decrypt.py verify-native`: check a passphrase against an encrypted database using only the standard library (PBKDF2-HMAC-SHA512 over the page-1 salt, then the SQLCipher page MAC). "Is this key right?" can now be answered when `sqlcipher3` is missing or built against a different SQLCipher - previously that question and a broken driver looked the same from the outside. It answers yes / no / cannot-tell, and does not raise on unusable input. The synthetic test fixture was also made faithful while adding this: it had been encrypting every shard with one fixed salt and the passphrase as a raw key, so it could not exercise per-shard key derivation at all.
|
|
1654
|
+
- Article topic and relevance in the daily report are now decided by TypeSafe's Jev decision model (`scripts/jev_client.py`) instead of being parsed out of generated text. One request asks a 6-way `choice` for the topic and a 3-level `score` for relevance, and returns probabilities rather than a string with `【主题】` in it. The LLM still writes the summary, tags and concepts - Jev cannot generate text. `weflow-cli config set typesafeApiKey "..."` enables it; without a key the previous path runs unchanged, and `--classifier llm` forces it. See D-031.
|
|
1655
|
+
- Two additive frontmatter keys on each article: `relevanceScore` (the raw score) and `topicConfidence`. The three-level cut points are provisional and uncalibrated, so the raw value is kept rather than discarded at the write step.
|
|
1656
|
+
- The daily report ends with **我拿不准的**: the entries whose probabilities sit near the threshold, split into those admitted and those excluded, plus any article whose topic confidence is low enough that it may be in the wrong section. It is computed **locally, from frontmatter** - no extra model call - because asking a model to describe its own uncertainty means asking it to generate more prose. When the articles predate the probability fields it says so outright, rather than printing nothing and letting the absence read as confidence. See D-033.
|
|
1657
|
+
- The daily report now asks whether an article belongs in the report, instead of inferring it. `worth_including` is a yes/no question riding along in the same decision request (measured: 12 questions cost 0.91s against 0.84s for 2, since `state` dominates the token count), and it is stored as `includeScore`. Observed to diverge usefully from `relevance`: an award announcement scored 0.79 for relevance but 0.03 for inclusion.
|
|
15
1658
|
|
|
16
|
-
|
|
1659
|
+
- `scripts/route_cards.py`: search your own conversations by describing what you are looking for. The decision model picks the real query words out of the n-gram fragments the question produces (measured: keeps 会议 at 0.77, rejects seven fragments at 0.11-0.40), and **ranking is local** - by how many messages in each session literally contain those words - because ranking sessions with the model was measured and does not work (all candidates scored 0.50-0.51 as yes/no and 1.74-1.76 on a 0/1/2 scale, regardless of 247 versus 8 actual hits). Retrieval reads WeChat's own `message_fts.db`, whose text is plaintext and whose integer `session_id` is the rowid of the same database's `name2id` table, so it needs no index of its own. Only the candidate words and your question leave the machine; `--keyword` keeps the whole path local, and message text never leaves. See D-036.
|
|
1660
|
+
|
|
1661
|
+
- Decision-model calls now record **which model actually served them**, and validate the `choice` contract on the way in. The API returns a `model` field naming the served version (`jev-1.13.0`) as distinct from the requested alias (`jev-latest`), and the daily stores it as `decisionModel` in `.articles.json` - so "production is no longer running what was measured" becomes visible instead of producing results that merely look fine. Every `choice` answer is checked before use: probabilities present, key set equal to the criteria, values in `[0,1]`, sum ≈ 1, and `choice` equal to the argmax; a non-argmax choice is not a wrong-looking answer but a normal-looking wrong one. `instructions` is also confirmed to accept a structured object (`{"goal": …, "rules": […]}`), though the daily's prompts deliberately stay as strings - changing them would move behaviour the cut points were calibrated against.
|
|
1662
|
+
|
|
1663
|
+
- When the decision model has judged an article, the LLM prompt no longer asks it to classify too. It used to ask for `【主题】`/`【相关度】` whoever was judging, so on the Jev path those answers were written and thrown away - the fields plus the six-category criteria and the three-level definitions came to 446 characters of prompt per article (57% of the prompt skeleton; about 9% of the total input, since the article body dominates), plus 10-15 output tokens. The **summary, tag and concept requirements are byte-identical between the two prompts** (a test asserts it), so what gets generated does not change; `--classifier llm` and any article whose Jev call failed still get the full prompt, keeping the fallback path byte-identical as D-031 requires. The choice is per article, not per batch - a failed judgement needs the LLM's own topic and relevance. Token savings are estimated from prompt size and output fields, not read from a bill: neither this client nor the decision-model client exposes usage amounts.
|
|
1664
|
+
|
|
1665
|
+
- The assistant's two untested core files now have coverage, via a synthetic harness rather than a
|
|
1666
|
+
live channel. `assistantMemory` gets 17 tests (working window, compression past the cap, the
|
|
1667
|
+
degraded path when the LLM refuses to compress, fact extraction cadence and noisy-output parsing,
|
|
1668
|
+
the fact cap, persistence across a restart) and `assistantService.handleMessage` gets 14 (built-in
|
|
1669
|
+
commands without any model call, the ReAct loop feeding a real tool result back, tool-result
|
|
1670
|
+
redaction, the unknown-tool and malformed-arguments paths, the 6-round cap, an LLM failure that
|
|
1671
|
+
must say so, audit lines, and memory wiring), plus 8 for the daemon start path. No network, no
|
|
1672
|
+
WeChat channel, no real home directory: `callLLM` is injected, the tools used are the two that
|
|
1673
|
+
only touch memory (`search_memory` / `save_memory`), and `HOME` is pointed at a temporary
|
|
1674
|
+
directory before the modules are imported. Two invariants the harness exists to hold: a tool
|
|
1675
|
+
result is redacted **before** it can leave the machine, and the audit log never contains message
|
|
1676
|
+
text. One sharp edge was found and **documented instead of changed**: fact de-duplication uses
|
|
1677
|
+
mutual containment, so an existing `事实 1` silently blocks a new `事实 10`.
|
|
1678
|
+
|
|
1679
|
+
- 11 of the assistant's 12 tool branches are now executed by tests, through stubs on the exported
|
|
1680
|
+
`chatService` / `wereadService` singletons and a replaced `fetch` - so no database, no network
|
|
1681
|
+
and no real home directory. Before this, no test had ever run a tool branch: only three pure
|
|
1682
|
+
helpers were covered, and the rest of the surface was unverified. The branches now pinned
|
|
1683
|
+
include: the strict-mode body mask applied **inside** `get_messages` (so third-party chat text
|
|
1684
|
+
cannot appear in a tool result), `read_favorite` refusing an unsafe link **without issuing any
|
|
1685
|
+
request at all**, its single retry for WeChat's WAF challenge page and the `content_noencode`
|
|
1686
|
+
fallback, the "deleted by the publisher" case, out-of-range tool arguments becoming a readable
|
|
1687
|
+
parameter error, ambiguous contact names asking for a more precise one (and an exact name
|
|
1688
|
+
winning over a partial match), and a tool that throws internally returning a readable failure
|
|
1689
|
+
instead of rethrowing into the ReAct loop. **Not covered, deliberately**: `get_todos` spawns a
|
|
1690
|
+
Python subprocess against the real database and has no cheap stub point.
|
|
1691
|
+
|
|
1692
|
+
- The assistant's resident loop is now driven by tests too - who gets answered and who is denied,
|
|
1693
|
+
without a WeChat channel: `WechatMessageService.prototype` is replaced, the token comes from a
|
|
1694
|
+
stubbed `configService.get`, and the queue is drained by awaiting it. Pinned: an empty allowlist
|
|
1695
|
+
denies everyone with an audit line and **no model call**; a non-text message is ignored without
|
|
1696
|
+
spending quota; an exhausted quota replies "额度已用完" and does not call the model; the quota
|
|
1697
|
+
resets across a date change; two messages are processed strictly in arrival order (the serial
|
|
1698
|
+
queue is what keeps the memory window from interleaving); a group needs all three controls
|
|
1699
|
+
(group allowlist, sender allowlist, @ mention) and is denied with a distinct reason for each
|
|
1700
|
+
missing one; and one message that throws does not take the loop down with it.
|
|
1701
|
+
|
|
1702
|
+
- The privacy wiring - which config value decides "this data does not leave the machine" - is now
|
|
1703
|
+
tested. Existing privacy tests called `redactText(text, mode, localInference)` with explicit
|
|
1704
|
+
arguments, so the function was covered while the thing that *derives* that boolean in
|
|
1705
|
+
production was not: `PrivacyGate.isLocalInference()` reads `aiEngine`, and `engineConfig()`
|
|
1706
|
+
decides where the request is actually sent. Pinned: only `ollama`/`lmstudio`/`local` count as
|
|
1707
|
+
local inference and skip redaction; a custom `aiBaseUrl` is still cloud, so swapping in a relay
|
|
1708
|
+
does not quietly stop PII masking; local engines point at `localhost` with `key: null` and a
|
|
1709
|
+
trailing slash on a custom base URL is stripped; and `reviewEvidence` refuses to put chat text
|
|
1710
|
+
on the wire without an explicit `--allow-cloud` (**without issuing any request**), redacts the
|
|
1711
|
+
transcript when it does, and keeps it intact under local inference.
|
|
17
1712
|
|
|
1713
|
+
- The assistant's single-round fast path (D-035) is implemented and **off by default**. It asks the local
|
|
1714
|
+
decision layer one question - "must this be answered from local data, and if so which of these nine
|
|
1715
|
+
capabilities" - then pre-dispatches that one tool, so the ReAct loop's first request already sees the
|
|
1716
|
+
result: two model round trips become one. Only tools with closed or empty arguments are routable
|
|
1717
|
+
(`list_sessions`, `get_stats`, `get_daily_report`, `get_sns`/`get_weread`/`get_todos` modes); a question
|
|
1718
|
+
like "summarise my chat with X" needs a free-text argument a closed-set question cannot produce, so it
|
|
1719
|
+
falls back - passing the raw message as the argument would manufacture exactly the failure this
|
|
1720
|
+
decision was written to avoid (a wrong tool, and a confident answer built on an irrelevant result).
|
|
1721
|
+
`config set assistantFastRoute log` records "would have routed to X" and changes nothing; `on` enables
|
|
1722
|
+
it. Every uncertain exit - judge unavailable, non-numeric probability (`Number(true)` is 1, so a boolean
|
|
1723
|
+
`noul` would have routed *certainly*), low confidence, unknown capability - falls back to the previous
|
|
1724
|
+
behaviour rather than to a weaker new one, and a test compares the fallback transcript field by field
|
|
1725
|
+
against the baseline. The dispatch-and-audit step is now one shared implementation, since "no tool
|
|
1726
|
+
dispatched without the audit line" is the property that must not regress. Measured on 17 questions
|
|
1727
|
+
against the real decision layer (`scripts/assistant_route_probe.ts`): 0 wrong routes, 10 routed
|
|
1728
|
+
concretely, ~1.33 s per decision, 2 fell back - with the probe's own expectations, not human labels.
|
|
1729
|
+
|
|
1730
|
+
- First-run setup for the assistant now says what to do and, more importantly, **which ID to allowlist is
|
|
1731
|
+
not guessed**. The assistant denies every sender until `assistantWhitelist` is set, so a fresh
|
|
1732
|
+
`login-wechat` used to end in silence: talk to your own bot, get nothing back, and go digging through
|
|
1733
|
+
logs for the reason. Two sources of that ID exist and only one is verified - the login response carries
|
|
1734
|
+
an `ilink_user_id` while the allowlist matches the inbound `from_user_id` (documented as an
|
|
1735
|
+
`@im.wechat` ID), and nothing in this repo connects the two, so the login path does not write the
|
|
1736
|
+
allowlist. Instead, while the allowlist is empty the daemon prints the **first denied direct message's**
|
|
1737
|
+
full sender ID together with the exact `config set` line to run, once, and never for a group (a group's
|
|
1738
|
+
sender is a member, not the person to allowlist). Auto-configuring an allowlist from an unverified
|
|
1739
|
+
identifier would have failed in the worst way available here - non-empty, so it looks configured, and
|
|
1740
|
+
still denying you, with the one-time hint disabled because the list is no longer empty.
|
|
1741
|
+
`assistant log --json` still returns metadata only, never log content.
|
|
1742
|
+
|
|
1743
|
+
- The assistant now names **all three** ways out when the strict privacy mode hides chat bodies. It had
|
|
1744
|
+
said only "switch strict mode off", which omits the option that actually matches a privacy worry: a
|
|
1745
|
+
local engine (`aiEngine=ollama`/`lmstudio`) leaves the machine out of it entirely, and strict-mode
|
|
1746
|
+
masking is skipped there by design because nothing is sent anywhere. The system prompt now requires
|
|
1747
|
+
it to list balanced (bodies leave, PII masked), a local engine (nothing leaves), or staying as is -
|
|
1748
|
+
rather than presenting one of them as the only choice.
|
|
1749
|
+
|
|
1750
|
+
- A `隐私` built-in command in the assistant: send it and the reply lists the current privacy mode, what the
|
|
1751
|
+
tools actually receive (masked, original, or not sent at all), and the exact `config set` lines to
|
|
1752
|
+
change it - including the local-engine option when inference is in the cloud. It is **read-only on
|
|
1753
|
+
purpose**: a chat message must not be able to weaken a privacy setting, so the mode is changed on the
|
|
1754
|
+
machine, not from inside the conversation.
|
|
1755
|
+
|
|
1756
|
+
- The assistant claimed it had called a tool and been blocked by strict mode, **without calling any tool at
|
|
1757
|
+
all** - the audit line read `TURN_DONE 264B tools=0`. Two things changed: the privacy state is now written
|
|
1758
|
+
into the system prompt as a **fact about the current mode** instead of a conditional ("if strict mode hides
|
|
1759
|
+
bodies, then ..."), and a rule says not to conclude anything about tool output before actually calling the
|
|
1760
|
+
tool. **The first explanation for it was wrong, and measurement said so**: this release's notes originally
|
|
1761
|
+
attributed the missing call to that conditional. A probe of the same question against the real model - four
|
|
1762
|
+
prompt variants (current, hypothesis-sentence only, with the model's own previous "blocked" answer seeded
|
|
1763
|
+
into its window, and the complete pre-change prompt) x 8 runs = **32 calls - called the tool 32 times out of
|
|
1764
|
+
32**. The prompt was therefore not the cause, and the cause of that single occurrence is unknown (sampling
|
|
1765
|
+
variance is the likeliest). The changes stay, because stating the current mode as a fact is right on its own
|
|
1766
|
+
terms, but they are **not** presented as a proven fix. What actually caught this was the audit line: a
|
|
1767
|
+
per-turn `tools=N` count turned "the assistant says it looked" into "the assistant did not look".
|
|
1768
|
+
- A **tool-use guard** in the assistant: if the router judged that a message needs local data and the turn
|
|
1769
|
+
then produced **no tool call at all**, the model is pushed back once - "you hold no tool result; call a
|
|
1770
|
+
tool or say which one you called and what it returned" - and the loop runs again. It exists because two
|
|
1771
|
+
live answers said "I did look it up, the content was masked" while the audit showed `tools=0`; 40 probe
|
|
1772
|
+
calls against the real model could not reproduce it, so this is a code-level **contradiction check**
|
|
1773
|
+
rather than a theory about the cause. The trigger is the routing decision, so: it never fires in `off`
|
|
1774
|
+
(no signal), and it **does** fire in `log` - deliberately, because `log` promises that *routing*
|
|
1775
|
+
changes nothing, while this is a safety behaviour, and the observation period is exactly when a
|
|
1776
|
+
fabricated "I looked" is most likely to be noticed. It fires at most once per turn, only on the
|
|
1777
|
+
contradiction, and a failed push-back keeps the reply it already had. `TOOL_GUARD_PUSHBACK` in the
|
|
1778
|
+
audit is the line to grep; the loop is now one shared implementation for both passes.
|
|
1779
|
+
|
|
1780
|
+
- **Non-text messages are no longer dropped when reading a conversation.** `parsedContent` was filled only
|
|
1781
|
+
for `local_type == 1`, and a non-text body is a zstd-compressed BLOB that "is not a str, so it becomes
|
|
1782
|
+
an empty string" - so images, stickers, files, quotes, transfers, red packets and **revoke notices**
|
|
1783
|
+
all arrived downstream as empty. In a real 44-message conversation the three "empty" messages turned out
|
|
1784
|
+
to be a revoke notice and two stickers, and the assistant answered, truthfully, that it could not read
|
|
1785
|
+
the content. They now get a display form: the type code is derived (`local_type = apptype * 2**32 + 49`,
|
|
1786
|
+
49 being the appmsg family) rather than tabulated, the XML is decompressed when it is zstd, and the
|
|
1787
|
+
useful part is carried through - `[文件] Base.csv`, `[引用] <quoted text>`, `"某人" 撤回了一条消息`,
|
|
1788
|
+
`[转账] 微信转账`, `[表情]`. An unknown app type says `[应用消息]` rather than guessing "link", and an
|
|
1789
|
+
unknown type says `[未识别的消息类型 N]`: **a non-text message never becomes an empty string again**, and
|
|
1790
|
+
a test asserts that over a list of types. The assistant tool now prefers `parsedContent` for non-text
|
|
1791
|
+
messages, so raw XML (md5, cdn urls) no longer goes into the model's context. Also fixed a latent wrong
|
|
1792
|
+
value: `getMediaStream` labelled **everything** that was not a video as `mediaType: 'image'`, text
|
|
1793
|
+
included (it has no callers today, but a wrong value in a public method gets believed eventually).
|
|
1794
|
+
|
|
1795
|
+
- The assistant's memory file now carries a **format version** (`version: 1`, conversations under a
|
|
1796
|
+
`users` key). A file without a version is the previous shape and is migrated; any other version is
|
|
1797
|
+
**refused** - renamed to `assistant_memory.json.unreadable-<timestamp>` and announced in the log and the
|
|
1798
|
+
audit - rather than parsed by guesswork. A file that cannot be parsed at all takes the same path, because
|
|
1799
|
+
it may be the only copy. The criterion for what counts as a structural change (and therefore a bump) is
|
|
1800
|
+
written down next to the constant: renaming/removing a field, changing its meaning or units, or changing
|
|
1801
|
+
the key space; **adding an optional field does not**, and a test pins that unknown fields in a
|
|
1802
|
+
same-version file are ignored.
|
|
1803
|
+
- Long-term facts gained **provenance and usage**: each fact records the user turn it was extracted from
|
|
1804
|
+
and the sentence that triggered it (`sourceTurn`, `sourceQuote`), and the time it was last retrieved
|
|
1805
|
+
(`usedAt`). A fact can now be checked against what the user actually said, and stale facts are
|
|
1806
|
+
distinguishable from live ones.
|
|
1807
|
+
- Fact de-duplication no longer loses the more specific version. It used to treat mutual containment as a
|
|
1808
|
+
duplicate, so `项目叫 weflow-cli` blocked `项目叫 weflow-cli 并开源` - the more precise statement could never
|
|
1809
|
+
be stored. Now the longer, more specific fact replaces the general one (normalised comparison, with a
|
|
1810
|
+
length-ratio guard so that two facts merely sharing a short fragment stay separate). The remaining
|
|
1811
|
+
ambiguity is documented in a test: two facts that are prefixes of each other resolve in favour of the
|
|
1812
|
+
longer.
|
|
1813
|
+
- The working window compresses on **two gates with different retention rules**, because a fixed turn count
|
|
1814
|
+
is window-independent and goes wrong as soon as the model changes. The turn gate (many short turns) keeps
|
|
1815
|
+
about half; the budget gate (a few long turns) keeps what fits 16% of the input budget, and the character
|
|
1816
|
+
bound wins over the turn floor - six 6,000-character turns are 36,000 characters, and no turn floor
|
|
1817
|
+
justifies exceeding the budget. Both rules are clamped to the window length, which fixed a real bug: a
|
|
1818
|
+
computed retain of 6 in a 5-turn window turned into `slice(-1)`, i.e. keeping exactly one turn.
|
|
1819
|
+
- The rolling summary is now a **fixed eight-section skeleton** (用户诉求 / 技术要点 / 涉及的文件与命令 /
|
|
1820
|
+
错误与修复 / 待办 / 当前进展 / 下一步 / 关键上下文) with an explicit merge law: keep what is still true, drop
|
|
1821
|
+
what is stale, produce one summary, never copy the previous one verbatim. An empty section writes
|
|
1822
|
+
`(none)`; a section is never dropped. Free-form summaries were where compression quietly lost information.
|
|
1823
|
+
The extraction prompt also now asks only for what **the user stated**, not for the assistant's guesses.
|
|
1824
|
+
- A memory file that could not be loaded is announced at startup (`⚠ 记忆: …` plus a `MEMORY_LOAD_ISSUE`
|
|
1825
|
+
audit line) instead of silently presenting an empty memory, which reads as "it forgot me".
|
|
1826
|
+
|
|
1827
|
+
- Facts are now **selected for injection by relevance** instead of all being sent every turn. Thirty facts at
|
|
1828
|
+
~72 characters each is about 2.1 KB per request, and the ones unrelated to the question are noise - which
|
|
1829
|
+
costs more than money. Selection ranks by direct containment first, then character-bigram overlap, then
|
|
1830
|
+
recent use (`usedAt`) and recency; the selected facts keep their chronological order so the block still
|
|
1831
|
+
reads as a list. The prompt now **says how many were left out** ("另有 N 条与这次问题关系较远,未列出"):
|
|
1832
|
+
claiming "here is everything I remember" while sending a subset is worse than sending less, because the
|
|
1833
|
+
model then believes it has seen it all.
|
|
1834
|
+
- Local data entering the system prompt (the rolling summary and the memory facts) is now wrapped in a
|
|
1835
|
+
`<weflow-local-data source="…">` frame, with **any occurrence of the frame tags inside the content
|
|
1836
|
+
neutralised**. The threat is frame spoofing: chat text, article text and a stored fact can all contain
|
|
1837
|
+
`</weflow-local-data>` followed by something that reads like a system instruction, and without escaping
|
|
1838
|
+
the data could close the frame and speak as the system. This does not make the model immune to
|
|
1839
|
+
instructions hidden in data - it only removes the ability to **close our frame**, which is the part we
|
|
1840
|
+
can guarantee. The idea came from the same `deepseek-harness` audit: it treats referenced session content
|
|
1841
|
+
as untrusted and keeps its injected instructions tag-safe for exactly this reason.
|
|
1842
|
+
|
|
1843
|
+
- Fact selection now has a **relevance floor**, applied **before** the budget cut. Measuring the earlier
|
|
1844
|
+
version showed the flaw: the budget is counted in characters, thirty short facts came to about 1,080
|
|
1845
|
+
characters, everything fitted, and "select by relevance" had degraded into sorting - i.e. full injection
|
|
1846
|
+
again (2,483 bytes of facts per request). With the floor, an unrelated question injects eight recent
|
|
1847
|
+
facts as a fallback (791 bytes) and a question matching one fact injects 390 bytes; the "另有 N 条" note
|
|
1848
|
+
appears in both cases. A relevance-only fallback is intentional: memory itself is context, so injecting
|
|
1849
|
+
nothing at all is worse than injecting the most recent few.
|
|
1850
|
+
|
|
1851
|
+
- Two more assistant tools, and the Python-calling code is now one implementation instead of two.
|
|
1852
|
+
- `search_chats`: "where did we talk about X" across every conversation. It runs `scripts/route_cards.py`
|
|
1853
|
+
(the decision model picks the query words, ranking is local against the message full-text index) and
|
|
1854
|
+
reuses that script's own egress gate; only the question and the candidate words leave the machine, never
|
|
1855
|
+
chat bodies - and the excerpts it does return go through `privacyGate.maskMessageBody`, the same rule
|
|
1856
|
+
`get_messages` follows, so strict mode masks them here too.
|
|
1857
|
+
- `who_owes_reply`: who is waiting on you, from `scripts/reply_debt.py --json`. It reports who/ how long /
|
|
1858
|
+
how likely and deliberately **not** what they wrote; ask for a specific person with `get_messages`, which
|
|
1859
|
+
does the masking properly.
|
|
1860
|
+
- `route_cards.py` gained a `--json` output for this (the JSON is the last line, since the human-readable
|
|
1861
|
+
progress lines still go first) with a test asserting that shape and that `--keyword` stays offline.
|
|
1862
|
+
- New `src/services/pythonBridge.ts`: the single way to call an in-repo Python script and get JSON back.
|
|
1863
|
+
There were two implementations before (`get_todos` via `execFile` with argv, the router via `spawn` with
|
|
1864
|
+
stdin), each with its own timeout and error handling - exactly the duplication this repo keeps finding.
|
|
1865
|
+
The bridge classifies failures (timeout / non-zero exit / no JSON / the script reporting `success:false`)
|
|
1866
|
+
instead of collapsing them into "execution failed", does not throw at callers, and is injectable so the
|
|
1867
|
+
tool branches can be tested without spawning a process - which is what finally makes `get_todos`
|
|
1868
|
+
testable, the one tool that had been left uncovered for that reason.
|
|
1869
|
+
|
|
1870
|
+
- Two more assistant tools, taking the set to 16.
|
|
1871
|
+
- `search_semantic`: meaning-based search for when literal words miss (`search_chats` matches literal strings
|
|
1872
|
+
only). The query travels in `WEFLOW_SEARCH_QUERY` rather than argv, following the rule this repo already
|
|
1873
|
+
tests for other readers - user text must not show up in a process list. Being explicit about the egress:
|
|
1874
|
+
the query is embedded by Aliyun (百炼 text-embedding-v4) and the candidate snippets are reranked by the
|
|
1875
|
+
decision model, both existing cloud paths in this repo, and it needs an index built first
|
|
1876
|
+
(`weflow-cli search-index`).
|
|
1877
|
+
- `export_chat`: write a conversation out as HTML with images. It is the first **write** tool the assistant
|
|
1878
|
+
has, so its boundary is fixed in code rather than left to the prompt: it only ever creates a **new**
|
|
1879
|
+
directory under `output/exports/` (the model cannot supply a path), the name carries a timestamp, and if
|
|
1880
|
+
that name is taken a numeric suffix is appended - "never overwrite" is a property, not an intention.
|
|
1881
|
+
The root is overridable with `WEFLOW_ASSISTANT_EXPORT_ROOT` (tests use a temp directory; a test run must
|
|
1882
|
+
not write into the repository).
|
|
1883
|
+
- Deliberately **not** tool-ified, with reasons recorded rather than left implicit: `evidence-review` and
|
|
1884
|
+
`vault promote` have their own preview-and-confirm gates for a human at a terminal, and routing a chat
|
|
1885
|
+
message around those gates would defeat the reason they exist; sending messages is outward-facing and
|
|
1886
|
+
irreversible, and the repository does not implement remote silent control; changing privacy settings, the
|
|
1887
|
+
allowlist or the sources stays on the machine.
|
|
1888
|
+
|
|
1889
|
+
- Three gaps in existing tools closed rather than three new tools added - each was a case where the CLI could
|
|
1890
|
+
already do it and the tool was just narrower than the question:
|
|
1891
|
+
- `get_sns` gained mode `users` ("who posts the most"), aggregated locally from the timeline it already
|
|
1892
|
+
fetches, so it adds no new egress; the CLI had this as a subcommand and the tool only had timeline/stats.
|
|
1893
|
+
- `export_chat` gained a `format` argument (`html` default, plus `txt`/`json`/`excel`). An unrecognised format
|
|
1894
|
+
is a parameter error, not a guess, and the reply names the format it wrote.
|
|
1895
|
+
- `search_favorites` no longer requires a keyword: with none it lists the most recent favourites, which is a
|
|
1896
|
+
question people actually ask. "The collection is empty" and "nothing matched that word" are now different
|
|
1897
|
+
sentences, as they were for the search case.
|
|
1898
|
+
|
|
1899
|
+
### Changed
|
|
1900
|
+
- Image downloads during the daily run are concurrent (6-way). They were sequential at 0.37 s and 135 KB each - about 18 minutes per 190-article day - even though they come from `.qpic.cn`, WeChat's CDN, which a browser fetches in parallel anyway. Same three articles: 17.2 s → 2.1 s. The same change fixed the map: a failed download used to be recorded in `.image_map.json` **before** it was attempted, and the reader injects that map as `window._IMG_MAP`, so the page was told to look for a local file that did not exist. Only files that are actually on disk are mapped now, and duplicates in a page are fetched once (31 image links in one article were 17 distinct images).
|
|
1901
|
+
- LLM summaries are generated concurrently, so a 190-article day spends about 2 minutes there instead of 9 (measured 2.27/2.92/2.45 s per article). The calls are **prefetched, not the loop rewritten**: responses are filled back by their original index and the existing loop still does the parsing and the field writes in the same order, so every fallback branch behaves exactly as before - a failed call comes back as an error and the loop re-raises it into its own `except`. The per-article 0.3 s pacing moved into the worker, so the request rate to the provider is unchanged. The stage now prints `摘要完成 N/M 篇,耗时 Xs(6 并发;串行约需 Ys)`, the shape the classification stage already used.
|
|
1902
|
+
- Daily article fetching now asks for compression. `urllib` does not send `Accept-Encoding` by default, so this machine was downloading every 公众号 article page - 3-4 MB of inline JS and CSS - uncompressed. Measured on the same four articles: **3.3-3.5 MB and 33-40 s before, 0.7-0.8 MB and 6-10 s after**, with the decompressed page identical in size and `js_content` / `rich_media_content` still present, and the parsed markdown byte-for-byte the same length. On a 190-article day that is roughly **130 minutes of fetching down to 33** - and fewer bytes cross the wire than before, which is the same direction as the deliberate per-article throttle rather than against it. Where the rest of a many-source day goes is now measured too: image downloads ~18 min (0.37 s and 135 KB per image, sequential), the 8-12 s inter-article throttle ~32 min, the serial summary loop ~11 min - so the throttle is now the largest single line item.
|
|
1903
|
+
- Daily classification now runs concurrently **before** the serial summary loop instead of one article at a time inside it. Classification has no dependency on the summary, so serialising the two only ever meant queueing N network waits behind N model calls. Measured on 12 real articles: 3.3s at 6 workers against ~12s serial, so a ~190-article day goes from ~184s to about 30s. The concurrent stage prints one progress line (`分类完成 N/M 篇`, with the elapsed time and the serial estimate) because per-article logs stop being meaningful once it is parallel; the `M` is the number actually asked, so an article that failed to classify is visible rather than inferred.
|
|
1904
|
+
- The three-level cut points for relevance had to be chosen before there was anything to calibrate against. Observed scores span `[0,2]`, so the levels are split at 0.5 and 1.5. On a 6-article trial with the shipped criteria nothing crossed into 高, so the report's `relevance != '高'` gate stays about as closed as before - it is now **able** to open, which it never was.
|
|
18
1905
|
- The HTML exporter now tries the conversation cache before the network when resolving an article thumbnail; it had been downloading covers it already had on disk.
|
|
19
1906
|
- `collectMessagesInRangeDetailed` distinguishes why paging stopped. The original loop ended on a short page exactly as it ended on an exhausted conversation, so a short page caused by offset shifting on a live database looked identical to having reached the end. The original function is unchanged; the variant is used by the new sync path and available to the export path next.
|
|
1907
|
+
- Message reads adapt to the shard's actual columns instead of assuming a fixed schema. The read used to select fourteen columns while consuming six, so a WeChat version renaming any of the other eight made every shard raise - and the conversation came back **empty with no error** unless `--report-shards` was used. The read now selects only the columns it consumes, and a shard that lost one is read anyway with the loss named in `missingColumns`. A shard where none of `create_time`/`local_id`/`server_id` survives is reported as `SCHEMA_MISMATCH` and makes `coverage` partial. `export_chat_html` reads rows by position, so there a missing column keeps its slot as `NULL` rather than shifting the fields after it - a shifted export would have been silently wrong data rather than an obvious failure.
|
|
1908
|
+
- `messages --from <unix>` / `--to <unix>` bound the read in SQL, and `sync` passes its window lower bound through. **This measured no faster** on the conversation used for development (519 ms unbounded vs 514 ms from the newest message, 925 messages over 4 shards) because the cost is process start plus 256000-round PBKDF2 per shard, not row transfer. It is there for very large conversations and as the prerequisite for a stable cursor; `sync` still applies the window itself, so backends without range support stay correct.
|
|
1909
|
+
- Two closed vocabularies that the code compares by literal are now declared once instead of twice. The message **anchor columns** (`create_time`, `local_id`, `server_id`) had three copies - a named one, a second literal in the same file's `ORDER BY`, and a third in the exporter - and moved into `nt_common` alongside the other shared NT plumbing. Their **order is behaviour**: one use is a set membership test (order irrelevant) and the other is the sort key, so listing `local_id` first would silently reorder every conversation read. A test pins `create_time` first, the tuple type (a set would let the order follow the interpreter's hash seed), and the literal's absence from both readers. The relevance levels moved to `_utils` beside `TOPICS`, with `DEFAULT_RELEVANCE` naming what an unclassified article is recorded as - the value every failed classification path lands on, and the reason the corpus once read 中 for 2199 of 2201 articles even though 中 reads as a positive judgement rather than "not judged". `extract_todos.py`'s identical `['高','中','低']` is deliberately left uncoupled: that is the `--urgency` vocabulary, which merely shares three characters.
|
|
20
1910
|
|
|
21
1911
|
### Fixed
|
|
1912
|
+
- The fetch guard in the assistant (`isSafeUrl`, used by `read_favorite` before it fetches
|
|
1913
|
+
a link found in a favourite) had two holes, found by testing it for the first time. **IPv6 was
|
|
1914
|
+
not handled at all**: `[::ffff:127.0.0.1]` (an IPv4-mapped loopback), `[fd00::1]` and
|
|
1915
|
+
`[fe80::1]` were all allowed, so a favourite could have pointed the assistant - a process
|
|
1916
|
+
holding the local database handle - at loopback or link-local space. In the other direction,
|
|
1917
|
+
the private-range regexes were matched against any hostname, so the legitimate public domain
|
|
1918
|
+
`10.example.com` was refused as unsafe. The dotted-quad checks now apply only when the
|
|
1919
|
+
hostname really is four dotted octets, and IPv6 is refused by prefix (`::`, `::1`, `::ffff:`,
|
|
1920
|
+
`fc00::/7`, `fe80::/10`). Measuring also retired an assumption: Node normalises integer IPv4
|
|
1921
|
+
forms (`2130706433`, `0x7f000001`) to `127.0.0.1` before this function runs, so those were
|
|
1922
|
+
never a bypass - now pinned by a test instead of believed. The guard remains a denylist of
|
|
1923
|
+
known forms, not a proof that an address is publicly routable (NAT64 is uncovered), and the
|
|
1924
|
+
code says so. See D-040.
|
|
1925
|
+
|
|
1926
|
+
- `assistant start` now actually starts the daemon, and reports success only when it does. The child
|
|
1927
|
+
was spawned without `--yes` while `assistant run` confirms through `inquirer` on a stdin the
|
|
1928
|
+
daemon had set to `ignore` - so the child died on the prompt while the parent printed
|
|
1929
|
+
`✓ 守护进程已启动 (pid …)` and wrote a pid file. `~/.weflow-cli/` contained **no `assistant.log`
|
|
1930
|
+
and no `assistant.pid` at all**, which is how the documented flow turned out never to have
|
|
1931
|
+
completed. The env marker that appeared to guard the path (`WEFLOW_ASSISTANT_DAEMON=1`) was read
|
|
1932
|
+
by nothing and was constructed as an env **key** containing `=`; both are gone. The parent
|
|
1933
|
+
confirms and passes `--yes` explicitly, and success is now observed: the child is watched for
|
|
1934
|
+
700 ms and a dead one is reported with its exit code and the log tail, **without** writing a pid
|
|
1935
|
+
file. A spawn `'error'` event is handled as well - without a listener Node turns it into an
|
|
1936
|
+
uncaught exception in the caller. Verified live: it now reports
|
|
1937
|
+
`子进程启动后立即退出 (code 1);日志尾部: Error: 未登录消息通道, 先运行 weflow-cli login-wechat`,
|
|
1938
|
+
which is the actual blocker on that machine (no `wechatOcToken`, empty allowlist). See D-039.
|
|
1939
|
+
|
|
22
1940
|
|
|
1941
|
+
- A daily run wrote **two different topics for the same article**. `.articles.json` defaulted a missing topic to `''` while the step that names the folder and writes the md frontmatter defaulted it to `学术` - seven lines apart, neither reporting anything. The 2026-09-04 output shows the split directly: 178 articles, every md under `学术/` carrying `topic: 学术` (including "OpenAI 深夜发布 GPT-6 Astra" and an AI-tool launch), and every entry in that day's `.articles.json` carrying `topic: ""`. Downstream reads the JSON, so the admission gate `topic != FOCUS_TOPIC and relevance != '高'` dropped the whole batch without a word. It is reachable without anything unusual: `daily --no-ai`, or a daily run with no API key, skips classification entirely, so no article has a `topic` key at all while the write phase still runs. The fallback is now one constant (`_utils.DEFAULT_TOPIC`) applied by one function that both normalises and groups (`biz_daily._group_by_topic`), so "the topic used for the folder" and "the topic written to the JSON" cannot be different values by construction. It validates **membership** in `TOPICS` rather than mere emptiness, and the run prints how many articles fell back, so a whole-batch fallback cannot pass as a normal classification. `test/default_topic_test.py` pins the grouping key, the md frontmatter and the JSON entry to the same value, and the fallback literal to one place; the checks were mutation-tested. The **same shape seven lines away** sat in `tags`: the md writer defaulted a missing key to `[topic]` and the JSON writer to `[]`, so those same 09-04 articles read `tags: ['学术']` in the md and `tags: []` in the JSON. Both now call `_tags_for_write`, which follows the convention the codebase already had (three classification paths write `[topic]` themselves) while still keeping a *present but empty* list empty - that is a different case, and collapsing it would just be a new default written in two places.
|
|
23
1942
|
- A leaked database handle when a shard opened but its key was rejected: the connection was left open, which on Windows keeps the file locked.
|
|
1943
|
+
- `coverage: 'partial'` now also covers a shard that opened but could not be read as asked (`SCHEMA_MISMATCH`, `WINDOW_UNAVAILABLE`) rather than only `READ_FAILED`. All three describe rows that were not read. A shard that never opened still reports `unverified`, unchanged. A shard read with columns missing is **not** partial - its rows did come back.
|
|
1944
|
+
- A shard whose `Name2Id` table is absent no longer loses its messages: sender names are unavailable, the rows are still returned.
|
|
1945
|
+
- A windowed read can no longer pass as covered when a shard cannot express the window. On a shard without `create_time` the read has nothing to filter on: it now says `WINDOW_UNAVAILABLE` instead of returning rows that the window filter then silently drops while `coverage` still said `complete`. No real WeChat schema observed so far triggers this - it is a hypothetical-path fix, tested against synthetic shards.
|
|
1946
|
+
- `awaiting --html <path>` writes a one-page, **self-contained**, shareable summary: headline counts, a table of verdicts with probabilities, debt age, conversation kind and evidence strength, and the uncertain bucket. It deliberately carries no chat text - only probabilities, counts and day counts - which is what makes it safe to show someone else. Contact display names are escaped, and the renderer passes through a fixed set of fields, so a message body attached to a row cannot leak into the page. The footer states what the numbers are not: no gold standard, run-to-run variance near the threshold, and that thin evidence is not a conclusion. See `--html`.
|
|
1947
|
+
- `scripts/quality_eval.py`: the way to finally check whether the probabilities mean anything. `sample` draws a stratified sample of real articles and scores each one through the **production** path, then writes a file with two blank fields per article (`topic`, `include`); `score` reads it back and prints topic agreement for both Jev and the stored labels, a **calibration table** (per probability bucket, what fraction the human actually said should be included), and a threshold sweep naming the cut point that best matches the human. Labels live in `~/.weflow-cli/labels/`, never in the repo. Every number it has produced so far has been a comparison against an imperfect baseline; this is the first thing that can replace that with a person's answer. 49 articles sampled and scored for ~$0.005.
|
|
1948
|
+
- `decide --over <glob> --ask <text>` batch mode: one glob times one set of yes/no questions expands into N×M questions in a single request, and the response carries a `batch` mapping so the caller never parses question names to find out which file an answer belongs to. `--max-chars` (default 1500) is reported back in the response, because the evidence you feed is what sets the ceiling - measured: the same 132 questions scored 77.3% agreement against a baseline at 14 lines per file and 88.6% at 1500 characters. `capabilities --json` now declares the two input paths separately (`readsLocalData: {request: false, over: true}`), since only `--over` reads local files.
|
|
1949
|
+
- `weflow-cli decide --request <file>`: a local judgement primitive. Takes a caller-supplied `{state, questions}` and returns typed answers with probabilities, plus the token count and the cost of the call, from one request. It reads no local data - the state is whatever the caller provides. Requests are validated locally so a malformed one fails with a reason instead of a service-side 422. **Not exposed over MCP**: an MCP client driving local outbound calls is a new egress surface and needs its own decision (recorded as `mcpExposed: false` in `capabilities --json`). See D-034.
|
|
1950
|
+
- `weflow-cli awaiting`: who is waiting on a reply. Asks one decision-model request per conversation and prints a ranked list; `--dry-run` previews what would be sent without any egress, `--yes` runs, and it is registered in `capabilities --json` (D-018). The underlying script is `scripts/reply_debt.py`: it asks one decision-model request per conversation whether the thread is sitting on a reply the user owes, and prints a ranked list with probabilities, debt age, and conversation kind. It is **not** the same question as `extract_todos.py`, which scans up to 80 conversations into a *single* LLM call asking whether any task was mentioned - that call has nowhere to record *who* is waiting. Judgements here are per-conversation, so each one knows whose it is. 25 conversations judged in ~5s at 6 workers. Each row prints the strength of its evidence, because a score derived from a two-character message is not the same claim as one derived from a paragraph. See D-033.
|
|
1951
|
+
- Search results are reranked by a decision model. `semantic_search.search()` scored by embedding similarity and took `argsort[:top_k]` - there was no second pass at all, and the keyword fallback (used whenever embeddings are unavailable) was cruder still. It now asks one question per candidate in a **single** request (~1s for 20 candidates; measured 0.84s at 2 questions against 0.91s at 12) and reorders by the probabilities, keeping `score` as-is and adding `rerankScore`. `--no-rerank` restores the previous behaviour. See D-032. Verified on real data: an article about PM2.5 and plant water-use efficiency, planted at position 4 of 8, came out first at 0.94 against 0.01-0.04 for the rest.
|
|
1952
|
+
- Transient decision-model failures are retried (429/5xx/529, short backoff). A live `HTTP 529 system_overloaded` was hit while building the reranker - the service launched on 2026-09-15 and being saturated is plausible. Auth failures are not retried.
|
|
1953
|
+
- A preview no longer requires the API key it is meant to help you decide about spending. `awaiting --dry-run` used to check for a key first and refuse without one, which put the cost of entry *before* the menu.
|
|
1954
|
+
- Non-text messages (images, voice, stickers) no longer reach a decision model as blank lines. WeChat stores them with empty `message_content`, so `reply_debt.py` sent `对方:` with nothing after it and got a confident answer back from the blank. They now carry a type label. See D-033.
|
|
1955
|
+
- `build_index` now indexes articles. `collect_articles()` filtered directory entries with `glob(' marriage*.md')` - a leading space and a `marriage` prefix, matching **0 of 188** files in a real topic directory. So the semantic index had never contained a single article, only chat messages, and nothing reported it. **The next `search-index` run will index articles for the first time and therefore consume embedding calls it never spent before** - that is a deliberate rebuild, not something a routine command should do silently.
|
|
1956
|
+
- `semantic_search` imports `numpy` and `sqlcipher3` lazily now (`require_numpy()` / `require_sqlcipher()`, the pattern `nt_decrypt.require_sqlcipher` already used). Previously a missing dependency called `sys.exit()` at import time, so the keyword-only and reranking paths - which need neither - were unusable on such a machine, and no test could import the module at all.
|
|
1957
|
+
- Batch mode in `decide` shipped without tests and crashed on first use: a deletion patch had left dead code in `main()` that the batch branch fell into, referencing an undefined variable. Found by running it, not by reading it, which is the point - `test/decide_test.py` now covers the batch path, including that it reaches the model at all.
|
|
1958
|
+
- `relevanceScore`, `topicConfidence` and `includeScore` never reached `.articles.json`, only the markdown frontmatter - and the report reads the JSON first. So the probability fields were invisible on the report's **primary** path. Introduced with the fields themselves, found by running a real dry-run and seeing the new section claim there were no probabilities, while the frontmatter plainly had them. Their serialization is now a named function with a test.
|
|
1959
|
+
- `dashscopeApiKey` is a first-class config key now: declared in `CliConfig`, **encrypted at rest** like the other secrets, and settable with `config set`. It had been read by `rag_chat` and `semantic_search` while not existing in the schema at all, so `config set dashscopeApiKey` was refused and the scripts' own error text could only tell the user to hand-edit `config.json`. Their reads now go through `_utils.get_dashscope_key`.
|
|
1960
|
+
- `config set favPassphrase` works. It was in `ENCRYPTED_KEYS` and read by `biz_daily` and `reply_debt`, but was missing from the `configurableKeys` allowlist - so a first-class secret could be neither set nor rotated through any documented path, and hand-editing a file whose field is ciphertext does not produce something usable.
|
|
1961
|
+
- Two static checks now enforce what that audit was looking for: every key a script reads is declared in `CliConfig` (`test/config_keys_declared_test.py`, with a justification-required exception list), and no script reads an encrypted key without decrypting it.
|
|
1962
|
+
- `nt_decrypt` and `export_chat_html` shared four same-named helpers that were **not the same code**, and they are now one implementation in `scripts/nt_common.py` (`discover_message_shards`, `derive_database_key`, `table_columns`, plus the exclusion set). The two differed in contract, not just in wording: `discover_message_shards` in `nt_decrypt` returned `[the configured database]` when it globbed no shards, while the exporter's returned an empty list and left the compensation to its call site. A second caller that forgot to compensate would silently read zero shards. `derive_database_key` and `table_columns` were byte-identical apart from docstrings. The never-empty contract is now the only one, the exporter's `if not shards: shards = [db_path]` is gone, and `test/nt_decrypt_shards_test.py` pins the single implementation by **identity** rather than by "the two agree" - two copies that happen to agree is exactly how this drifted in the first place.
|
|
1963
|
+
- `nt_decrypt.load_contact_names` no longer leaks its connection when the read fails: `conn.close()` sat at the end of the `try`, so any exception before it left the handle open, which on Windows keeps the file locked (the same shape as the shard-read leak fixed earlier). It also keeps the names it did read instead of discarding all of them.
|
|
1964
|
+
- The keyword-search fallback no longer returns generated report artifacts as articles. Searching for 环境 空气质量 returned **行动建议 — 2026-08-30**, a file the daily pipeline writes, not an article. The artifact exists at **two depths** (`<day>/行动建议.md` and `<day>/<topic>/行动建议.md`, four of each on the real data), so filtering by path is not enough - the fix uses the same predicate `quality_eval` already applies: a real article carries `url:` in its frontmatter. The index-building path never had this problem because it only walks topic directories, which is why it went unnoticed: **the two paths disagreed, and the unfiltered one is the one that actually runs** (the index has never been built on this machine).
|
|
1965
|
+
- Four scripts read `deepseekApiKey` straight out of the config file while every other reader decrypts it first: `annual_report`, `classify_daily`, `extract_todos` and `rag_chat`. Since that key is in `ENCRYPTED_KEYS`, `config set` stores it as `lock:<ciphertext>` - so those four would hand `lock:...` to the provider as an API key and report an auth failure that says nothing about why. **It has not surfaced only because on this machine that one key happens to be plaintext**, written by something that bypassed `config set`; every other encrypted key on disk is ciphertext. Rotating the key with `config set` would have broken all four at once. They now go through `_utils.get_api_key`, and `test/encrypted_config_test.py` statically fails the build if any script reads an encrypted key without decrypting it - while accepting the repo's existing `*_enc` convention (hold the ciphertext, decrypt later), which seven correct call sites use.
|
|
1966
|
+
- Four declared-but-unused things are gone, each of which described behaviour the code did not have: `biz_daily.MAX_ARTICLES = 50` with a comment claiming it capped fetching (never read; `--limit` is what works, and a normal day is 150-270 articles); a `CONFIG_PATH` in the same file (config goes through `_utils.load_config`); `_utils.parallel_map` (a concurrency helper with no callers); and fifteen lines of **unreachable** code after `search()`'s return, which returned a `dict` while the live code returns a `list` - a dead block documenting a contract the function no longer has. Their imports went with them.
|
|
1967
|
+
- The article prompt no longer contradicts itself about the topic list. `TOPIC_PROMPT` generated its "must be one of" line from `TOPICS` (six categories) but hardcoded **five** in the two reminders further down, and the judging rules had no entry for 政治 at all - a category that accounts for 26% of the corpus. Both reminders and the rules are now generated from the shared table, so the LLM path is told to pick from six categories *and* given a definition for each. **This can change topic output on the LLM path** (which is the fallback when no decision-model key is configured): a category that previously had no criteria now has one.
|
|
1968
|
+
- The topic taxonomy has a single definition. `TOPICS` had been copied into six files and `TOPIC_CRITERIA` existed separately in `jev_client`; both now live in `_utils` and every consumer imports them, so the prompt path and the decision-model path judge by the same table. `test/topic_taxonomy_test.py` pins it by identity, not equality - copying a list and getting it right is exactly how it drifted the first time.
|
|
1969
|
+
- The admission filter's log line claimed the threshold was applied even when it was not: on articles without `includeScore` it printed `(阈值 includeScore >= 0.5)` while the old `relevance == '高'` rule was doing the filtering. It now names the rule it actually used, including the mixed case. A log that misdescribes what it did is worse than no log.
|
|
1970
|
+
- The daily report's admission rule is now actually applied. `if topic != FOCUS_TOPIC and relevance != '高': continue` existed only in the markdown-scan *fallback* loader; the primary loader (`.articles.json`, present on every normal run) returned every article unfiltered, so the rule had never taken effect. Both loaders now call one predicate, and `--include-all` restores the previous collect-everything behaviour. **This changes what the report contains**: non-focus articles will be fewer, because previously there were no filters at all.
|
|
1971
|
+
- `relevance` is now assigned on **every** path through the daily classification loop. It used to be set on two of five: `category_hint` hard-coded it to `中`, and the AI-exception and short-content paths left it unset for the writer's `fm.get('relevance', '中')` to catch. Measured over the 2201 stored articles, 2199 of them carried that default. That alone would have left `generate_ai_report.py`'s gate filtering on topic only; the gate turning out to be unreachable on the primary loader made even that generous.
|
|
24
1972
|
|
|
25
1973
|
## 1.6.4
|
|
26
1974
|
|