weflow-cli 1.11.1 → 1.13.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/CHANGELOG.md +378 -0
- package/OPERATIONS.md +359 -11
- package/README.md +4 -0
- package/bin/weflow-cli.ts +289 -37
- package/dist/bin/weflow-cli.js +300 -35
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +5 -0
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +12 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +23 -3
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +8 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +46 -11
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +3 -1
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +25 -7
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +6 -3
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +80 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -0
- package/dist/src/services/diagnosticReport.js +191 -0
- package/dist/src/services/diagnosticReport.js.map +1 -0
- package/dist/src/services/exportService.d.ts +7 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +1 -1
- package/dist/src/services/exportService.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +9 -3
- package/dist/src/services/wechatClient.d.ts.map +1 -1
- package/dist/src/services/wechatClient.js +55 -22
- package/dist/src/services/wechatClient.js.map +1 -1
- package/docs/DECISIONS.md +662 -1
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +236 -0
- package/docs/POSITIONING.md +68 -0
- package/docs/PROJECT_STATE.md +119 -17
- package/docs/RELEASING.md +14 -0
- package/package.json +88 -86
- package/scripts/_utils.py +120 -4
- package/scripts/backfill_articles.py +64 -14
- package/scripts/biz_daily.py +118 -11
- package/scripts/daily_task.ps1 +132 -0
- package/scripts/export_chat_html.py +79 -31
- package/scripts/install_tasks.cmd +32 -0
- package/scripts/install_tasks.ps1 +91 -0
- package/scripts/jev_client.py +335 -3
- package/scripts/nt_decrypt.py +79 -14
- package/scripts/pipeline.py +21 -0
- package/scripts/recall_eval.py +247 -0
- package/scripts/repro_customer_init.cmd +86 -0
- package/scripts/semantic_search.py +926 -51
- package/src/core/dbPathService.ts +5 -0
- package/src/core/ntCore.ts +34 -3
- package/src/core/sqlcipherCore.ts +47 -11
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/configService.ts +12 -3
- package/src/services/diagnosticReport.ts +237 -0
- package/src/services/exportService.ts +14 -2
- package/src/services/wechatClient.ts +418 -380
package/docs/PROJECT_STATE.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
WeFlow CLI is a local-first command-line tool and MCP server for user-authorized WeChat data workflows: local chat queries and exports, official-account daily reading, favorites, knowledge workflows, and an optional local assistant.
|
|
8
8
|
|
|
9
|
+
**Why it exists next to WeChat itself** is written down in [POSITIONING.md](POSITIONING.md): the value is not "AI over the content WeChat already shows" (4.1.15 added exactly that for files and articles) but the layer WeChat does not provide - data that can leave the client, cross-content/cross-time queries, local inference, and answers traceable to the original message.
|
|
10
|
+
|
|
9
11
|
## Current Baseline
|
|
10
12
|
|
|
11
13
|
- Source package version: take it from `package.json` -> `version` rather than from this file - a hardcoded copy drifts silently, and `package-lock.json` must always match it. The npm registry may remain on an earlier version until a maintainer publishes a release.
|
|
@@ -19,10 +21,19 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
|
|
|
19
21
|
- npm package contents: what ships is `package.json` -> `files`, and it is an allowlist of **directories and globs** - a file added just outside a listed pattern is dropped with nothing going red. `test/pack-contents.test.ts` asserts the positive side against a real `npm pack --dry-run --json`: every tracked file under `scripts/` ships except the dev-only `.ts`/`.mjs` ones (the `.mjs` is a verification tool that needs a GPU browser), every local image the two READMEs reference ships, and no `dist/` file lacks a matching source (`tsc` never prunes, so a renamed source used to keep shipping - four such files were removed on 2026-10-11). The negative side (nothing from `output/` or `models/`, no keys) is the CI `release-consistency` job. Both halves mattered on 2026-10-11: the published `1.9.0` tarball was missing `scripts/data/wechat_emoji.json` (the emoji fallback map `scripts/wechat_emoji.py` reads) and `logo.png`, so npm installs rendered a batch of face codes as literal text and the package page showed a broken logo.
|
|
20
22
|
- Downstream data contract: local JSON export is the first compatibility boundary for `she-love-me` and future consumers; see `docs/DATA_CONTRACT.md`.
|
|
21
23
|
- Supported focus: Windows WeChat 4.x; Linux WeChat 4.x has an NT database path; macOS requires user-provided local access credentials for initialization.
|
|
22
|
-
The version actually verified on this machine is **4.1.15.
|
|
23
|
-
process's `--user-lib-dir
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
The version actually verified on this machine is **4.1.15.56** (2026-10-19; before it 4.1.15.53 on 2026-10-18, and
|
|
25
|
+
4.1.15.13 before that) - read from the live process's `--user-lib-dir` when it is there, and otherwise from the
|
|
26
|
+
`Weixin.dll` that process actually has mapped (4.1.15.53+ carries **no** switches at all - D-125), **not** from the
|
|
27
|
+
version literal inside `Weixin.exe` (that one is only a fallback for when the primary load path fails; leftover
|
|
28
|
+
version *directories* are flagged, see `docs/CONTACT_DB_SCHEMA.md`, which also records which ones are usable).
|
|
29
|
+
**Every upgrade kept the derived keys working**, verified each time by reading real data: 4.1.15.53 on 2026-10-18
|
|
30
|
+
(from 4.1.15.13) and **4.1.15.56 on 2026-10-19** - after that one, `sessions`, `messages`, `contacts`,
|
|
31
|
+
`daily-stats` (official-account DB), `fav` (favorites DB) and `sns stats` (4,164 cached posts) all returned data
|
|
32
|
+
with the keys derived under the older client. 4.1.15.56 was obtained through the client's own updater (it is not on
|
|
33
|
+
the public download page or the GitHub version archives, which still served 4.1.15.53 on 2026-10-19), so a user on
|
|
34
|
+
it is not on a different product line. The customer-reported `init` failure (`NT scan failed (exit
|
|
35
|
+
9009)`) is therefore **not** a client-version problem; the `9009` message itself was verified
|
|
36
|
+
end-to-end against a real process that exits `9009`.
|
|
26
37
|
|
|
27
38
|
## Verified Capabilities
|
|
28
39
|
|
|
@@ -30,13 +41,13 @@ WeFlow CLI is a local-first command-line tool and MCP server for user-authorized
|
|
|
30
41
|
| --- | --- | --- |
|
|
31
42
|
| Local chat data | Query sessions, contacts, messages, favorites, Moments cache, and exports. HTML chat exports decode NT compressed media records, locally cached WeChat 4.x V2 image containers, entity-escaped emoji XML, and remote emoticons; app cards preserve links and embed available公众号/Bilibili covers. Media matching prefers server-message resources, exact local-message-ID plus timestamp pairs, and a unique local-ID fallback; high-bit encoded image rows are resolved through the same conversation cache. | `sessions`, `contacts`, `messages`, `export`, `fav`, `sns` |
|
|
32
43
|
| Initialization | Verify and reuse existing local database access by default; refresh only when needed. Missing-key tests can run without changing saved configuration. Data-root discovery asks the **running client** first (its command line carries `--wechat-files-path`; the other two routes guess from the folder name) and reports which source won; a hint still has to pass the same structural check. | `init`, `init --refresh`, `init --test-missing-keys`, `config forget-keys`, `check` |
|
|
33
|
-
| Environment / diagnosis | `check` (and the scheduled `health_check`) reports the **WeChat build actually loaded**, read from the live process's `--user-lib-dir` rather than
|
|
44
|
+
| Environment / diagnosis | `check` (and the scheduled `health_check`) reports the **WeChat build actually loaded**, read from the live process's `--user-lib-dir` and - since 4.1.15.53 launches the main process with no switches (D-125) - falling back to the `Weixin.dll` that process actually has mapped, rather than to the version literal inside `Weixin.exe` (that one is only a fallback - D-107). It flags version directories that cannot work (no `Weixin.dll`). When the client is not running it says *unknown*, and a missing field counts as a failure so a stale build cannot pass. Verified on this machine: `4.1.15.53` loaded (2026-10-18, after updating from 4.1.15.13), `4.1.15.11\` (one file) flagged as a leftover. `--report` renders the same facts as a **paste-ready, redacted verdict** (environment, client, key presence, then "what is broken and the next command"), so a user can hand an AI or an issue something better than a description of the symptom (D-126). | `check`, `check --json`, `check --report`, `scripts/health_check.py` |
|
|
34
45
|
| Relationship temperature | Who is going quiet, who is heating up, and who is reachable **only** here. Reads the local message database directly: read-only, no model, no network (D-062). Three groups - `cooling` (substantial history then silence, ranked by messages x silent days), `heating` (last 7 days at least twice the long-run daily rate), `only-wechat` (no phone/email/meeting-up ever appears in the history). Only 1:1 chats, only text messages, and only accounts whose **id shape** is a person: anything with `@` (`@chatroom`/`@openim`/`@weclaw`), `filehelper` or `gh_` is out - ranking by volume alone produced no information, and the first irreplaceability rule was inverted (it is trivially true of bots, so it put a coffee-shop bot first). Accounts that look like a person but whose name reads as a shop are **flagged, not dropped**, and every exclusion is printed with its reason. Deliberately **not** in the assistant or MCP tool tables: its output is an inference about third parties. | `bonds` |
|
|
35
|
-
| Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, classify them, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. Topic (6-way), relevance (3-level) and "does this belong in today's report" (yes/no) are decided by TypeSafe's Jev when `typesafeApiKey` is configured - concurrently, before the serial summary loop (12 articles in 3.3s at 6 workers against ~12s one at a time), and by the previous LLM-parse path otherwise; `--classifier llm` forces the latter. The report's admission rule is applied on both loader paths and can be bypassed with `--include-all`. | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
|
|
46
|
+
| Official-account daily | Filter configured sources, preserve source categories, backfill incomplete yesterday output before an unqualified today run, fetch articles, classify them, create summaries, generate a local HTML reader, and synchronize reader favorites into local files. Topic (6-way), relevance (3-level) and "does this belong in today's report" (yes/no) are decided by TypeSafe's Jev when `typesafeApiKey` is configured - concurrently, before the serial summary loop (12 articles in 3.3s at 6 workers against ~12s one at a time), and by the previous LLM-parse path otherwise; `--classifier llm` forces the latter. The report's admission rule is applied on both loader paths and can be bypassed with `--include-all`. Two flags exist for the unattended case: **`--yesterday`** (a scheduled run wants *the previous day*, since accounts publish until midnight, and "yesterday" is time-insensitive - the same data whenever the machine wakes up) and **`--prefetch-bodies [n]`** (default off; warms the body cache concurrently *before* the serial, deliberately throttled fetch phase, which then runs entirely from cache - the upstream request count is unchanged, only the window it is spread over). | `daily`, `daily favorites`, `daily-stats`, `daily-server` |
|
|
36
47
|
| Knowledge workflows | Wiki compilation (`compile_wiki` aggregates `[[concept]]` links into Vault pages; **the article corpus produced none as of 2026-09-26** (the 1,633 article notes then in the Vault carried only their own topic link and `## 关联网络` path links, measured over the whole corpus), so an article-side concept-extraction step was missing; `article-notes` (added the same day) is that step, and it works: 30 article notes produced 110 concepts and 10 concept pages on this machine, which are now the first knowledge pages the assistant can search (`vault search`, `search_knowledge`). The corpus has grown since - 7,119 concept cards, and 22,374 concept pages across both lines as of 2026-09-28. The degeneracy reported in that trial was an artefact of a 120-card sample: with all 710 September articles carded, the topic field splits into 学术/新闻/政治 (62/33/25 of 108 pages), so the earlier "the classifier puts everything in one bucket" judgement is withdrawn. Two upstream noise sources showed up in that trial. The first - WeChat reader UI text inside article summaries - is **fixed**, and the backfill this line used to defer has since been run: measured over the corpus it was 1,484 of 1,633 notes (the corpus has grown to 34,092 since), the cleaner runs on both write paths, and `create_reading_notes --clean-summaries` took the existing notes to **0**; the Vault's `Sources/WeChat` copies were cleaned on 2026-09-28 as well (D-055), so the residue now survives only in `output/biz-daily`, which is the archive. The second is **measured but not fixed**: of 224 notes whose titles read as news, 40 are labelled `学术` - that classifier belongs to the daily pipeline and changing its criteria without labels would be a guess. Fed by both sources: `output/biz-daily` articles and, since 2026-09-26, `output/chat-notes` conversation cards produced by the confirmed `chat-notes` command - which, since 2026-10-01, carries voice messages as **text** rather than a bare `[语音]`: it reads the local faster-whisper transcript cache (content-addressed, `output/.voice-cache` - one definition, in `wechat_voice`) and labels each one `[语音·本机转写·未校对]`, because a machine transcript of someone's speech may be wrong and the model must not quote it as fact. Measured on the 30-day window: **54 of 125** voice messages carry text that used to be a placeholder; the ones still missing are counted and printed, and `--transcribe-voice` fills them locally (the cache is the progress, so an interrupted run resumes); **the graph is also kept free of duplicate nodes by `wiki compile --merge-duplicates`** (74 groups merged, 80 pages removed, and 98 links that pointed at the removed names still resolve through `aliases` - see D-053); **most of the article line's pages were never written by a model** - `wiki compile --pages-from-cards` assembles a page from material the cards already carry (the definition is the card's own `desc`, the related concepts are the other concepts from the same article, the source is that article) and marks it `summary_by: card`. That label is deliberate and is not the same as "no model was involved": the `desc` line was written by `article_notes`, so the quality is that line's quality, and a few read as meta-descriptions of where the term appeared rather than as definitions. 17,383 pages at ¥0; the model-based route was priced at ¥31 and rejected, because every one of those concepts is mentioned by exactly one article (D-054); **the pages accumulate only if `wiki compile --refresh-sources` is run** - `wiki compile` itself only builds pages for concepts that do not have one, so an existing page never gains an edge (measured: one run added 3,164 edges across 324 article-line pages; on 2026-10-01, after the whole
|
|
37
48
|
pre-2026-03-03 window had been carded, another run added **8,551** edges across **3,506** pages - verified against a
|
|
38
49
|
snapshot of all 48,426 pages: the file count changed, the frontmatter that changed is **0**, and the files that lost
|
|
39
|
-
a source line are **0**, so the rewrite is what it claims to be, additive; it rewrites the body only, never the frontmatter, and never deletes a source line no current card accounts for - see D-052), semantic search (two-stage: similarity, then a decision-model rerank over the top 20 candidates; `--no-rerank` reverts) - the index covers 90 days of chat and 30 date-directories of articles **by default**, and `search-index --days / --article-days` changes that (the numbers were hardcoded at the call sites until 2026-09-25, and neither the command nor its preview said so), RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. The article line was extended on 2026-10-01 with the **whole**
|
|
50
|
+
a source line are **0**, so the rewrite is what it claims to be, additive; it rewrites the body only, never the frontmatter, and never deletes a source line no current card accounts for - see D-052), semantic search (two-stage: similarity, then a decision-model rerank over the top 20 candidates; `--no-rerank` reverts) - the index covers 90 days of chat and 30 date-directories of articles **by default**, and `search-index --days / --article-days` changes that (the numbers were hardcoded at the call sites until 2026-09-25, and neither the command nor its preview said so; a **gated local BM25 + vector fusion** is the default since 2026-10-15 (the gate is what makes it work: fragment recall@10 0.91 -> 1.00, hit@1 0.60 -> 0.80, nothing worse - D-111; `--no-hybrid` reverts), an index was then built on this machine on 2026-10-15: 16,027 records (10,759 articles + 5,268 chat messages) out of ~5.87M characters, ≈¥2 at list price), RAG, WeRead sync, reviews, reading notes, and staged Vault promotion. The article line was extended on 2026-10-01 with the **whole**
|
|
40
51
|
pre-2026-03-03 backlog, not only its AI part: **8,754** articles over 157 days (1,702 AI + 7,052 others, of which
|
|
41
52
|
2,709 needed fetching), **8,670** reading notes (free and local - 94 fewer than the index because a note file is named
|
|
42
53
|
`<date>-<title>` and one title republished by two accounts the same day makes a single file; measured per day, that
|
|
@@ -93,7 +104,7 @@ mistook a **missing mechanism** for a **tool that cannot be tested**: the harnes
|
|
|
93
104
|
`assistantPrivacy` per case all along, it just had not generalised it. With a general `config` override
|
|
94
105
|
(`weread-notebooks`, asserting the book title really reaches the answer, because reading `b.title` off the
|
|
95
106
|
notebooks response yields `undefined` while the output still looks fine), what genuinely remains uncovered is
|
|
96
|
-
**one** tool: `search_semantic` (needs `dashscopeApiKey` **and** a built index -
|
|
107
|
+
**one** tool: `search_semantic` (it needs `dashscopeApiKey` **and** a built index; as of 2026-10-15 this machine has both - the index holds 16,027 records - so it stays uncovered for a different reason: its query vector is a **second egress**, and the harness admits only the model call). That
|
|
97
108
|
list is no longer prose that can rot: it is the `EVAL_UNCOVERED` map, and `test/assistant-eval.test.ts` asserts both
|
|
98
109
|
directions - every tool is either claimed by some case's assertions or registered there with a reason, and no
|
|
99
110
|
registered name has stopped existing - so adding a tool means writing a case or writing a reason, and skipping both
|
|
@@ -129,10 +140,11 @@ few weeks. Each row is an index entry - the measurements and the reasoning live
|
|
|
129
140
|
| --- | --- | --- |
|
|
130
141
|
| The **semantic (vector) search path** (D-105) | **Partly closed on 2026-10-13**: the index now exists (built with a 7-day chat / 3-article window - 749 records, inside DashScope's free quota) and the vector path has been exercised for real, per line (`indexCounts` 383 + 366 = 749). Still open: the **default 90/30 window** was never built (it exceeds the free quota; measured cost ≈ ¥1.3-2.2), and the **rerank layer cannot run on this machine** - it calls Typesafe (`api.typesafe.ai`) and answers `HTTP 451: Typesafe is not available in your region`, so the default path here is the un-reranked one. | Two things, both cheap but not done here: (a) decide whether the degraded search should stay byte-identical to a successful un-reranked one - `rerank` returns only the list, so nothing in the JSON says it failed; (b) build the 90/30 window if the small one proves useful. |
|
|
131
142
|
| `update\update.data`'s **internal format** (D-107) | The report reverse-engineered the loader but not this file, and the file does not exist here. When it does exist the loaded build may not be the one the command line names, so `check` **states that** instead of guessing. | A machine where the file exists. |
|
|
132
|
-
| The **version token ↔ dotted version** mapping (D-107) | The **low 16 bits are decoded** (2026-10-11): byte 3 is the fourth component, byte 2 is the third `+ 0x10` -
|
|
143
|
+
| The **version token ↔ dotted version** mapping (D-107) | The **low 16 bits are decoded** (2026-10-11): byte 3 is the fourth component, byte 2 is the third `+ 0x10` - three pairs now agree (`0xF2541F0D`→4.1.15.13, `0xF2541F35`→**4.1.15.53**, 2026-10-18, plus the older pair). The high 16 bits are `F254` on all of them, and all three versions are `4.1.x`, so major/minor cannot be separated (n=3: "all fit", not "solved"). | More (build, token) pairs, or the encoder. `update\download` and `update\patch` are empty on this machine, and the client's `log/*.xlog` (53 files, 373 MB) is **not plain text**: grep for the token there returns 0 matches, which is a **false negative** rather than evidence, and single-byte-XOR zlib/zstd does not unlock it either. The falsifiable prediction to test: `4.1.15.11 → 0xF2541F0B`. |
|
|
133
144
|
| The MMKV stores under `%APPDATA%\Tencent\xwechat\radium\mmkv\` (D-101) | Their format holds (crc32 precondition passes; one is empty), but the only key we can construct does not open them, and the `config/` root key is random and was never captured. | **Deliberately not attempted**: the remaining route is injecting into the client process, which this repo does not do (D-101). |
|
|
134
145
|
| **Why** the data root uses hard links (CONTACT_DB_SCHEMA §6.2) | The effect is measured (395 groups, 407 MB double-counted if you sum sizes); the cause is the report's `hardlink.dat` inference (multi-account / multi-version reuse) and has not been established. The file itself was read on 2026-10-11: it is **32 hex characters** (a 16-byte value), one copy on this machine, older than both installed client versions - so it is a marker or seed, not the link inventory (16 bytes cannot describe 395 groups). | Finding what consumes it. One testable guess, not yet tried: use those 16 bytes as an AES-128 key for the `radium\mmkv\` stores, judged by the D-101 oracle (crc32 consistent **and** the whole record sequence walks). The stores are on this machine; the tooling is not. |
|
|
135
146
|
| Issue #7 (HTML emoji export) | Open pending reporter verification. | The reporter's confirmation. |
|
|
147
|
+
| Issue #11 (documents in the non-HTML formats) | **Answered 2026-10-13 and fixed in 1.11.0/1.11.1** ([comment](https://github.com/zhuobichen/weflow-cli/issues/11#issuecomment-6160700152)); the issue is left open for the reporter, as with #7. The fix itself was re-checked against a real export and five defects were found that way (names, control characters, reserved device names, a manifest that a narrower run overwrote - see CHANGELOG 1.11.1). Still open: **the document-body path has only ever been measured on this machine** - one account, 1,855 file messages, 88.6% resolved, and the remaining 11.4% *look* like files that were never downloaded here but that reading is itself unverified. | The reporter's confirmation, plus a second machine or account - that is what would separate "our matching misses files that exist" from "those files are not here". |
|
|
136
148
|
| The CLI's **runtime** error paths under `--json` (~100 prose sites in `bin/weflow-cli.ts`) | The argument-validation family **is** audited (80 sites, 0 defects, 2026-10-11) and `weread`'s nine one-line failure branches are fixed, but the rest are runtime failures - "not configured", "no WeChat process", permission denied - whose reachability under `--json` depends on that command's interactive gate. A window around a line cannot tell "mentions `opts.json`" from "is governed by `opts.json`": that filter answered wrongly in **both** directions on 2026-10-11 (305 candidates, then 12, of which the real family was nine). | Reading each site, or driving each command into its failure state where that needs no credentials and no network - `weread`'s empty-`HOME` case is the worked example (`test/json-error-surface.test.ts`). Two named gaps: `export --contract` is verified **on this machine only**, because its check runs after `resolveTalker()`; and the human-facing error arm is not uniform across the CLI (most sites `console.error`, some `console.log`) - a separate, user-visible change that this work deliberately did not make. |
|
|
137
149
|
|
|
138
150
|
**What this is not**: a bug list. Everything above works on the paths this machine exercises; what is missing is
|
|
@@ -164,10 +176,10 @@ Documentation was synchronized with the current source baseline on 2026-09-19. C
|
|
|
164
176
|
|
|
165
177
|
- Issue #7 remains open pending reporter verification. HTML emoji export preserves the emoji label and matches resource records only by nonzero server-message identity; local IDs are not unique across shards or conversations. WeChat 4.x V2 image containers are decoded with an account-specific media key derived from local `kvcomm` data and verified against a real cached V2 header. Remote emoticons use AES-CBC with the message key as both key and IV. Entity-escaped emoji XML is normalized before extracting MD5 and media URLs; failed encrypted downloads fall back across available thumbnail/CDN fields. Signature-only default `[打脸]` messages use the bundled official Facepalm asset when no message-specific resource is available. Forwarded app cards remain structured links with available covers, and Bilibili share pages can resolve covers through their BV metadata endpoint when no `og:image` is present. Forty-two synthetic Python regression tests (`export_chat_media_test.py`, `export_chat_media_report_test.py`) cover these paths; run `python -m unittest discover -s test -p '*_test.py' -v`. CI also runs these tests. This does not establish compatibility with every real WeChat media format.
|
|
166
178
|
|
|
167
|
-
- **Media leaves the export as files now, images and documents alike, and the matching happens once.** `export … md` writes images to `<output>/<talker>_assets/` and references them relatively; `export … json|txt|excel --media` runs the same Python extraction and then writes the requested format against the same `manifest.json`. The switch existed because a reporter asked whether the non-HTML formats omitted images *and documents* - images had just been fixed, documents had not, and **no format exported a document body** (the HTML page showed `[文件] <name>`). Documents are file-message appmsg `type=6`: the row carries only name/md5/size, and the body sits in the account directory's `msg/file/YYYY-MM/` **with no per-conversation directory**, so the index is a stat-only `(normalized name, byte size)` lookup and md5 disambiguates only when a name is ambiguous (WeChat appends its own `(1)`). Measured on this machine: 1,855 file messages, 1,489 unique matches + 154 md5-resolved = **88.6%**, and the remaining 212 have no local body at all (never downloaded, or cleaned up) - those stay as `[文件] name` labels and are *counted*, not guessed (D-014). Bodies are copied under their original names and extensions; the manifest is `weflow-media-manifest/v2` (values carry `kind`) and v1 files still read. Two traps are worth keeping: `<refermsg>` sits **inside** `<appmsg>`, so searching the XML for the first matching tag reported 7,083 "files" that were nearly all quoted text (the parser now reads `<appmsg>`'s direct children only); and a count of newly copied files was reported as the number of documents exported, so a second run into the same directory printed "0 document(s)" with 67 documents on disk. **HTML deliberately embeds no document bodies** (a multi-MB PDF as base64 in a page nobody can open), and voice/video still have labels only.
|
|
179
|
+
- **Media leaves the export as files now, images and documents alike, and the matching happens once.** `export … md` writes images to `<output>/<talker>_assets/` and references them relatively; `export … json|txt|excel --media` runs the same Python extraction and then writes the requested format against the same `manifest.json`. The switch existed because a reporter asked whether the non-HTML formats omitted images *and documents* - images had just been fixed, documents had not, and **no format exported a document body** (the HTML page showed `[文件] <name>`). Documents are file-message appmsg `type=6`: the row carries only name/md5/size, and the body sits in the account directory's `msg/file/YYYY-MM/` **with no per-conversation directory**, so the index is a stat-only `(normalized name, byte size)` lookup and md5 disambiguates only when a name is ambiguous (WeChat appends its own `(1)`). Measured on this machine: 1,855 file messages, 1,489 unique matches + 154 md5-resolved = **88.6%**, and the remaining 212 have no local body at all (never downloaded, or cleaned up) - those stay as `[文件] name` labels and are *counted*, not guessed (D-014). Bodies are copied under their original names and extensions; the manifest is `weflow-media-manifest/v2` (values carry `kind`) and v1 files still read. Two traps are worth keeping: `<refermsg>` sits **inside** `<appmsg>`, so searching the XML for the first matching tag reported 7,083 "files" that were nearly all quoted text (the parser now reads `<appmsg>`'s direct children only); and a count of newly copied files was reported as the number of documents exported, so a second run into the same directory printed "0 document(s)" with 67 documents on disk. **HTML deliberately embeds no document bodies** (a multi-MB PDF as base64 in a page nobody can open), and voice/video still have labels only. **One vocabulary now covers all three artifacts** (`*_media.json`, `manifest.json`, the CLI's JSON): each word answers exactly one question, so no two of them can be read as the same claim. `found`/`missing` (report statuses, documents) say whether the item could be **located in the source data**; `written`/`copied`/`unavailable` (manifest `documents`, and the same three inside the CLI's `media.documents`) say whether it was **materialized into the export directory** - `written` is what is there now, `copied` is what this run added, `unavailable` is what could not be written (not found, too large, or the copy failed). The report says `found` for a file whose copy then fails, and the manifest says `unavailable` for it: two different questions, two true answers, where the old shared word `exported` would have made "已导出" false. All three branches (html / md / `--media`) now emit that summary from one function (`media_json_summary`), so they cannot disagree, and it carries the report's file name (html was the only one that used to).
|
|
168
180
|
|
|
169
181
|
- WeChat platform behavior, database formats, account restrictions, and terms can change without notice. Local operation is not a legal, account-safety, or platform-compatibility guarantee.
|
|
170
|
-
- **The daily is no longer all-or-nothing.** It fetches every article before writing anything and the bodies lived only in memory, so any interruption threw the whole day away - a 400-article day is over an hour of fetching, and the 2026-09-22 run stopped at 105/403 with nothing written. Bodies are now cached under `output/.cache/fetch/` keyed by URL, so a re-run fetches only the missing ones (measured: a resumed batch of 3 goes from 64 s to 2.0 s, and cache hits take **no** throttle sleep because they send no request - the throttle exists to spare the upstream, and this is the same direction). Only successes are cached, so a failure is retried next time rather than remembered. `--no-fetch-cache` forces a refetch. **The lesson that made this urgent**: a long single-shot run is a gamble, and `cmd | tee log` reports the wrapper's exit code, not the program's - the 105/403 failure looked like `exit 0`.
|
|
182
|
+
- **The daily is no longer all-or-nothing.** It fetches every article before writing anything and the bodies lived only in memory, so any interruption threw the whole day away - a 400-article day is over an hour of fetching, and the 2026-09-22 run stopped at 105/403 with nothing written. Bodies are now cached under `output/.cache/fetch/` keyed by URL, so a re-run fetches only the missing ones (measured: a resumed batch of 3 goes from 64 s to 2.0 s, and cache hits take **no** throttle sleep because they send no request - the throttle exists to spare the upstream, and this is the same direction). Only successes are cached, so a failure is retried next time rather than remembered. `--no-fetch-cache` forces a refetch. **Since 2026-10-14 the historical backfill writes and reads this same cache** (it calls `fetch_article_cached`, and carries the same `--no-fetch-cache`), so a backfilled day can be produced by `daily --date X` afterwards **without re-fetching what the backfill already got** - measured on 2026-10-13: backfilling populated 145 cache entries (57 -> 202 files) and those 145 became cache hits for the daily. **The overlap is partial and should not be read as "the day becomes free"**: the daily fetches every article with a url (it wants full bodies for summaries) while the backfill only fetches the ones whose local body is under 100 characters, so on that same day **340 of 485 articles still needed the network** (~55 min at the deliberate throttle). Making the backfill pre-fetch everything would remove that, but it means *more* upstream requests and `docs/NEXT_ACTIONS.md` says not to add requests through a default change - so the default stays as it is, and an explicit opt-in is the only shape that work could take. Not implemented; recorded in D-108. **The lesson that made this urgent**: a long single-shot run is a gamble, and `cmd | tee log` reports the wrapper's exit code, not the program's - the 105/403 failure looked like `exit 0`.
|
|
171
183
|
- Where a many-source day spends its time is now measured rather than assumed, and two of the three biggest items have been fixed. A 190-article day **was** ~3 h 10 m: fetching bodies ~130 min, the deliberate 8-12 s inter-article throttle ~32 min, image downloads ~18 min (47 image links per three articles, 0.37 s and 135 KB each, sequential), and the serial summary loop ~9 min (measured 2.27/2.92/2.45 s per article, plus 0.3 s each). It is now ~1 h 5 m: fetching ~33 min (gzip, below), throttle ~32 min, images ~3 min (6-way concurrent), summaries ~2 min (6-way concurrent). Classification was never a factor - already concurrent. **The throttle is now the largest single item, and it is the one thing left that is deliberate** ("reduce upstream pressure"); the other two costs were implementation choices, not policy. Both new concurrency settings are 6 (`IMAGE_WORKERS`, `SUMMARY_WORKERS`), matching `JEV_WORKERS`.
|
|
172
184
|
- **Article bodies are now fetched with `Accept-Encoding: gzip, deflate`**, which `urllib` does not send by default. 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 - so ~130 min of fetching becomes ~33 min on a 190-article day, and **fewer bytes cross the wire than before**, which is the same direction as the throttle. `_decode_body` recognises only the two encodings the request asks for, and passes anything else through unchanged: raising there would turn a transfer optimisation into a fetch failure. The relative cost of the remaining parts is unchanged, so the throttle is now the largest single line item.
|
|
173
185
|
- The article body is **not** available locally, so fetching cannot be skipped: a 公众号 message in the local database carries title, digest, URL and metadata, and stripping its XML tags leaves 0 Chinese characters (the ~5-12 KB that remains is URLs, base64 and ids).
|
|
@@ -185,9 +197,15 @@ Documentation was synchronized with the current source baseline on 2026-09-19. C
|
|
|
185
197
|
- **The LLM is no longer asked to classify when the decision model already has.** The summary prompt carried `【主题】`/`【相关度】` plus the six-category criteria and three-level definitions whoever was judging, so on the Jev path those were generated and discarded: 446 characters of prompt per article (57% of the prompt skeleton, ~9% of total input since the body dominates) and 10-15 output tokens. `SUMMARY_ONLY_PROMPT` drops exactly those sections and a test asserts the **summary/tag/concept requirements are byte-identical** between the two prompts, which is what keeps the change from altering the output rather than just its cost. The switch is **per article**: `--classifier llm` and any article whose judgement failed still get the full prompt, because those need the LLM's own topic and relevance - so the fallback path stays byte-identical per D-031. Savings are estimated from prompt size and output fields; neither client exposes billed usage.
|
|
186
198
|
- **Judging no longer requires generating.** `daily --no-summary` keeps the decision model for topic/relevance/inclusion and skips every LLM call inside the daily step, so a machine with no DeepSeek key can still classify. Two things make it honest rather than convenient: the LLM-key checks had to be relaxed in **both** callers (`biz_daily` and `pipeline`), because they encode "Phase 2 needs an LLM key" - a claim that stops being true once nothing is generated; and the artifacts must not imply a summary that does not exist, so the md omits the `## AI 摘要` section entirely and `.articles.json` records `summary: ""` (an empty heading reads as a failed generation, the platform digest reads as our summary - both would be claims about something absent). Tags fall back to `[topic]`, the convention that already covers "no tags were extracted". The scope is deliberately the daily step only: the pipeline's later steps (action suggestions, wiki compile, AI report) still call an LLM and say so at runtime; a fully LLM-free run combines it with `--skip-classify --skip-wiki --skip-ai-report`. Verified with a deliberately invalid DeepSeek key: the run completes with no 401, so nothing reached the LLM.
|
|
187
199
|
- **The decision model's free period ends 2026-09-25**, after which input tokens are billed (output has been free during the promotion). Anything expensive to re-measure should be measured before then, or the cost of a re-run should be assumed. The client only ever sees token counts, not a billed amount, so cost stays an estimate from tokens times price - the same position the reference implementation (`browser-use` × TypeSafe) documents. Verified against the live API on 2026-09-23: `usage` is exactly `{input_tokens, output_tokens, model}`. There **is** a route that reports money - the same model is served by OpenRouter as `typesafe/jev-1.13` and that response carries `usage.cost` - so "what does a daily actually cost" is answerable, but only by adding a second vendor to the request path, which has not been done.
|
|
200
|
+
- **The second vendor now exists, and the region block is measured rather than assumed (D-120, 2026-10-17).** `judgeProvider` selects among **three** transports for the whole decision layer (`native` default / **`chat`** / `openrouter`) - the same client serves the daily's topic/relevance judgement, search reranking, reply debt, drafted replies and route-card term selection, so one switch moves all of them. `chat` is the one that matters here: it reuses the conversational model the summary path already uses (`aiBaseUrl` relay / `aiModel` / `deepseekApiKey`), so **no new vendor and no new key**, and it was measured on one real article with the same prompt and the same validators - **`deepseek-chat` passes the Jev contract in 1.0 s** (1,649 prompt + 69 completion tokens), `deepseek-reasoner` in 7.2 s, `qwen-plus` in 2.3 s, while `qwen-turbo` was refused by that account's own rate limit (429, not a region block). This is now the configured path on this machine. Two corrections to the paragraph above: the OpenRouter slug is **`typesafe/jev-router`**, not `typesafe/jev-1.13` (gone from the 466-model list), and **the region filter is per model, not per site** - `typesafe/jev-router` and `:free` models pass from here while `openai/gpt-4o-mini` returns `403 This model is not available in your region`. **A passing contract is not a passing judgement**: on that same article `deepseek-chat` returned relevance 1 / inclusion 0.15 and `qwen-plus` returned 2 / 0.85, and the report's thresholds (0.5 / 1.5 / 0.50) were set against Jev - so a judge swap is a ruler swap, which is exactly why the raw scores and `usage.model` are stored. That paid off immediately: a request for `deepseek-chat` was served by **`deepseek-flash`**. OpenRouter cost is measured too (**$0.014** for one article on a call routed to `x-ai/grok-4.7`, 1,535 of 1,598 completion tokens being reasoning; the router picks a different upstream per call, so budget the worst case, ~$7 for a 500-article day, and `max_tokens` does not cap it) - and **that path cannot run on the key tested here at all**, since `/credits` reports `total_credits: 0` and calls are refused with 402. The contract now lives on our side for both chat paths, so the prompt plus four validators are in `jev_client.py` and drift raises `JevError` instead of yielding a plausible-looking default; 34 offline tests pin the shape, the key-name mapping (`prompt_tokens`→`input_tokens`, which decides whether cost reporting silently reads 0) and the four distinct failures. What is **not** verified: whether these values are *right* - there is still no gold standard, and `quality_eval.py sample` (50 articles, hand-labelled) remains the open item.
|
|
201
|
+
|
|
188
202
|
- **The decision-model path has never completed a production daily run.** Measured on the archive: of 2203 articles, **4** carry `relevanceScore` (2026-09-05, from a `--date` re-run while the feature was being built) and **none** carries `includeScore`; the last complete day is 2026-09-05, and 2026-09-22's directory exists but is empty because that run stopped at 105/403 fetches (the failure 1.7.0's fetch cache was built for). So "classification now uses Jev" describes the code and a 60-article comparison, not a day that has happened. Every threshold quoted above (relevance `0.5`/`1.5`, inclusion `0.50`) is still the guess it was written as, and the switch is unexercised at day scale. `quality_eval.py sample` drives the same production call over a 150-article pool, which is the closest it has come; the daily itself is the thing that has not run.
|
|
189
203
|
- Relevance in the daily report is split into 高/中/低 at cut points that are **provisional** (0.5 and 1.5 on a `[0,2]` score). The raw score is stored as `relevanceScore` so recalibrating does not require re-running a day. On a 6-article trial with the shipped criteria, nothing reached 高.
|
|
190
204
|
- Search reranking is fail-soft by design: no key, a network failure or an overloaded service (`HTTP 529` was observed live) all return the first-stage order with a warning. Its one real hazard is candidate numbering - a model that answers question `c3` against candidate `c5` sinks the best hit with no error at all - so every candidate is labelled in the state and a separate single-choice question cross-checks the argmax, warning on disagreement. Verified on real data before shipping, but only on one planted query.
|
|
205
|
+
- **Literal-string location is now a separate leg from similarity, and it is deliberately local (D-121, 2026-10-17).** `search --phrase` finds a phrase **verbatim** via SQLite's bundled **FTS5 + `trigram`** and reports hits grouped by account × date ("which account wrote this, when") - the questions the other two legs cannot answer at all (a code name, a proper noun, a policy phrase, a quotation check), because BM25 scores terms and vectors score meaning. Measured on the shipped corpus (15,995 records, built in 10.7 s into 96.9 MB): `沙箱逃逸` 2 hits across two accounts, `PM2.5` **58** (including the chat line), `腾讯混元` 7, `分布式光伏` 1, and `std::vector`/`光伏补贴政策` **0** - the last two being the honest reading of "verbatim". Four facts were measured rather than assumed: the default `unicode61` tokenizer is **useless for Chinese** (`沙箱逃逸` matched 0), `trigram` matches contiguous substrings rather than a term AND (so it cannot replace the keyword leg), it has a **3-character floor** (short queries return an explicit `tooShort`, never "0 hits"), and punctuation in a query is parsed as **syntax** (`PM2.5` errored until the string was quoted as a literal). The index is **derived** from the semantic index's `meta.json` rather than re-enumerating the database - two enumerations drift, and "the two indexes do not cover the same records" is a failure that reports nothing. It runs with no key, no network and no confirmation gate, and **it is not scheduled**: the semantic index changing means rebuilding it, which is the same open freshness problem, not a second one.
|
|
206
|
+
- **Reranking is the largest quality lever in the search path, and the candidate pool is what carries it (D-123, measured 2026-10-18).** Same 40 queries, same seed: no rerank `hit@1 0.550 / recall@5 0.650 / nDCG 0.630`; pool 20 `0.675 / 0.800 / 0.756`; **pool 100 (the default) `0.825 / 0.925 / 0.884`**. So the ~9 s the rerank costs per query buys the largest measurable gain in the system, and cutting the pool to 20 for a 5x speed-up would cost ~0.15 on every metric - the "optimisation" is refused by the data rather than by taste. Two mechanisms (the noul contract vs the JSON-scores fallback) measured **equivalent at the same pool** (0.800 vs 0.825 hit@1, identical recall), which is what made a clean pool comparison possible after a first, confounded one. Two instrumentation defects surfaced in the process and are fixed: `search()`'s bare `except: pass` silently turned any vector-path failure into a keyword-only search while still reporting `hybrid: true` (now `vector: {used, fallback}`), and `RERANK_STATE.provider` named the *contract* rather than who served it - with `judgeProvider=chat` the Jev contract is satisfied by DeepSeek, so `provider: typesafe` read as "the native API worked" when it had not (now `transport` records the client identity). The lesson is written down: **pin the mechanism before comparing a parameter**.
|
|
207
|
+
- **The `chat` judge path now follows `aiEngine`, so choosing a local engine is no longer overridden (D-124, 2026-10-18).** D-120's `judgeProvider=chat` branch read `deepseekApiKey` unconditionally, so a user with `aiEngine=local` who turned the judge on sent article text to DeepSeek **while nothing in the UI changed** - a privacy boundary broken by a transport choice, not a cost question. `local`/`ollama` now routes through `detect_local_engine()` (`provider='chat-local'`, the same contract validators), an undetected local service returns `None` with a WARN rather than falling back to the cloud, and `claude` is refused explicitly instead of being swapped for another vendor. Seven tests cover the dispatch with a stub engine; **a live Ollama/LM Studio run has not been done**, so the claim is "dispatches correctly and satisfies the contract", not "the local judge is good".
|
|
208
|
+
- **`init` now distinguishes "finished" from "ran", and Windows `9009` carries its own explanation (2026-10-18).** A real report ended with `NT scan failed (exit 9009)`, "✓ 已沿用配置中的密钥", "初始化完成! (4.x 模式)" and "密钥: 已安全保存", then `sessions` was empty on every run. The chain: `getPythonCommand()` falls back to the **bare name `python`** when no candidate passes its probe (runnable *and* `import sqlcipher3`), which on Windows is frequently the Microsoft Store placeholder that exits `9009`; the scan therefore never ran, so "the existing key" had never been checked against this database, and the closing summary was unconditional. The exit code is now translated into the requirement it actually encodes, and the summary is gated on whether **this run** obtained a key (memory-scan match or passphrase derivation) - otherwise it prints a failed-init heading and the three steps that fix it. The underlying fallback in `getPythonCommand()` was deliberately left alone: other commands do work on a plain `python`, and turning the fallback into a hard failure would break them to improve one message.
|
|
191
209
|
- `build_index` had never indexed any article (`glob(' marriage*.md')` matched 0 of 188 files), so semantic search covered chat messages only. The glob is fixed; **the article corpus will enter the index on the next rebuild**, which spends embedding calls, so it is a deliberate action rather than a side effect.
|
|
192
210
|
- Every config key a script reads is declared in `CliConfig`, so `config set` can set it; `test/config_keys_declared_test.py` enforces this with a justification-required exception list. Two keys were found by that audit: `dashscopeApiKey` (read by two scripts, absent from the schema entirely) and `favPassphrase` (a first-class encrypted secret that the allowlist refused). Both are settable now, and `dashscopeApiKey` became encrypted.
|
|
193
211
|
- `bizKey` / `bizSalt` are read by three scripts and written by nothing, so that branch is unreachable. **This entry used to add that `biz_message_0.db` "is always opened via the key derived from `favPassphrase`", and that half was never checked against the three callers**: only `biz_daily.py` had the fallback - `chat_stats.py` and `mcp_bridge.py` errored out with "missing key" and pointed the user at `config set bizKey`, a key that is not in the CLI's writable allowlist, so following their own advice reached `INVALID_CONFIG_KEY`. `biz_daily.py` additionally computed the same key **twice in a row** (a `bizKey` pass immediately overwritten by a passphrase pass), which is what a fork in the logic leaves behind. **Fixed on 2026-10-01 rather than left recorded**: the derivation now exists once, in `_utils.biz_message_db()`, all three call it, and `test/biz_message_db_test.py` asserts that no other script reads `bizKey` - a pattern check, so a script added later is covered too. **No configuration surface was added**: the dead branch was deleted rather than given a knob to turn, which is exactly what the original note was protecting against. Verified by running the path that used to fail (`daily-stats` now returns per-account counts; a daily dry run opens the database).
|
|
@@ -270,7 +288,19 @@ fallback is a defect report, not a statistic** - it was in the output and read p
|
|
|
270
288
|
`'学术' != 'AI'` in a test made it undeniable. `test/backfill_articles_test.py` now pins it, including a mutation
|
|
271
289
|
check (replacing the guess with the default fails three tests).
|
|
272
290
|
|
|
273
|
-
**Historical backfill is a separate line from the daily pipeline, and stays separate
|
|
291
|
+
**Historical backfill is a separate line from the daily pipeline, and stays separate - but the two now share one
|
|
292
|
+
body cache.** Since 2026-10-14 the backfill fetches through the daily's own `biz_daily.fetch_article_cached`
|
|
293
|
+
(write-through to `output/.cache/fetch/<md5(url)>.md`, successes only), so a backfilled day can be produced the
|
|
294
|
+
official way afterwards without touching the network: `daily --date X` reads those bodies and its 8-12 s
|
|
295
|
+
inter-article throttle never fires on a cache hit (the sleep lives in the daily's *call loop*, not in the fetch
|
|
296
|
+
function). The pipelines themselves stay separate for the reasons below - different outputs (text vs
|
|
297
|
+
HTML+images), different fetch policy (concurrent, no throttle here vs serial and deliberately throttled there) -
|
|
298
|
+
and each side keeps its own `--no-fetch-cache` for a forced refetch (same name, same meaning: skip the read,
|
|
299
|
+
still write the result back). **Who warms that cache and who reads it:** since 2026-10-16 `daily
|
|
300
|
+
--prefetch-bodies` warms it concurrently before the fetch phase (so the phase is cache hits and the throttle
|
|
301
|
+
never fires), and the backfill warms it as a side effect; **the semantic index does not read it at all** -
|
|
302
|
+
feeding full bodies to the embedding was measured that day and showed no gain (D-118), so the cache serves the
|
|
303
|
+
display path (`search --with-text`) and the daily's running time, not retrieval. Neither of the two
|
|
274
304
|
image-carrying outputs is needed for the knowledge base, and the difference is not marginal: the daily path stores
|
|
275
305
|
`output/biz-daily/<date>/` (article HTML plus images, ~1.1 MB/article) *and* an identical copy under
|
|
276
306
|
`wechat-vault/Sources/WeChat/<date>/` (`pipeline.py:133`), so the same 35,463 articles would cost ~65 GB the daily
|
|
@@ -280,7 +310,14 @@ were symptoms of the same mismatch, that this path was built for one day at a ti
|
|
|
280
310
|
reads a title and an account name, never the body, which is what makes pre-filtering possible at all and also what
|
|
281
311
|
bounds the claim: the AI set is the keyword's, not the daily line's model's, and these articles have never been
|
|
282
312
|
through a model. Partially backfilled days are marked (`topicFilter`, plus `truncated` for a `--limit-per-day`
|
|
283
|
-
slice
|
|
313
|
+
slice, plus `fetchFailed` when a body fetch failed **in that run**) so `day_done` will not mistake one for a
|
|
314
|
+
finished day - D-060. `fetchFailed` differs from the other two in one way worth knowing: it is **not sticky**,
|
|
315
|
+
because a later full run retries the failed articles (they are still short-bodied, so they stay in the todo list,
|
|
316
|
+
and the ones that succeeded are cache hits) - so a run with zero failures clears the marker. Keeping a fetch
|
|
317
|
+
failure and a genuinely short body apart matters: the first is retryable, the second is a property of the
|
|
318
|
+
article, and before the split a single network hiccup was written down as "skipped N (body too short)" with the
|
|
319
|
+
day still marked complete, i.e. those articles were buried for good (measured elsewhere in this file: 28 of 84
|
|
320
|
+
failures were transient and did come back on retry). **Run the same day**: the AI subset is
|
|
284
321
|
fetched and on disk - **1,702 of the 1,758** in-scope articles across 156 days (96.8%), needing 1,208 network fetches;
|
|
285
322
|
`output/biz-daily/<day>/AI/` holds them, no other topic directory was created, every one of those days carries
|
|
286
323
|
`topicFilter`, and a full run would skip **0** of them. The 56 not pulled are not a fetch defect: re-probing all 84
|
|
@@ -359,10 +396,75 @@ line too (D-105): `search`, `chat`, `vault search`, `vault rag` and the assistan
|
|
|
359
396
|
`search_knowledge` all take `line: wiki|chat|all`, default to the article line where the user sees it (the
|
|
360
397
|
*functions* default to `all` so a caller that forgets cannot silently halve the search), and every output says which
|
|
361
398
|
line it searched. The semantic side carries this as a mask applied **before** the top-k sort over one shared index.
|
|
362
|
-
**Limitation
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
399
|
+
**Limitation, updated 2026-10-15**: the vector path now runs against a **real** index on this machine (the user
|
|
400
|
+
supplied a `dashscopeApiKey`; the index holds 16,027 records - 10,759 articles + 5,268 chat messages - and real
|
|
401
|
+
searches were run through the CLI). What the measurement changed is the *shape* of the limit: no longer "never
|
|
402
|
+
exercised", but "the article line ranks weakly" - raw cosine only, because rerank is blocked here (Typesafe returns
|
|
403
|
+
`HTTP 451: not available in your region`), scores clustering inside 0.49-0.58 and **not comparable across queries**
|
|
404
|
+
(0.58 on one query need not mean "closer" than 0.50 on another), and a compound query losing its rarer half
|
|
405
|
+
("卫星遥感反演气溶胶光学厚度" returned 气溶胶 papers first and 遥感 at rank 4). **The cause was found the same
|
|
406
|
+
day, and it was not the truncation**: none of the 10,759 daily `.md` files in that window contains `## 正文`, so
|
|
407
|
+
every article fell back to `content[:1000]` and only YAML metadata was embedded (sampled 300: 100% contain
|
|
408
|
+
`title:`, median CJK ratio 0.16). The extractor now reads the real structure instead (0.16 → 0.80 median CJK
|
|
409
|
+
ratio, 299/300 files) with a test that fails if it is reverted; **the index needs a `--full` rebuild to pick that
|
|
410
|
+
up**, because an incremental build embeds new ids only (see D-109). Cost was measured the same day: the build shipped ~5.87M characters (~¥2
|
|
411
|
+
estimated), one query ~12 tokens. The mask itself is still pinned against a hand-made fixture index, since a fixture
|
|
412
|
+
**A recall baseline now exists (measured 2026-10-15, index of 15,995 records), so the next change has something to
|
|
413
|
+
be compared against.** Three batteries, all with a known correct answer: (a) 80 auto queries built from a record's
|
|
414
|
+
own text - 12-character verbatim fragments recall@10 = **13/20** on the article line and **12/20** on chat; titles /
|
|
415
|
+
first-30-characters recall@10 = **20/20** on the article line and 16/20 on chat; (b) 10 hand-written questions, each
|
|
416
|
+
with a literal "the answer must contain this" anchor: **10/10 present in the index**, first anchor-bearing record at
|
|
417
|
+
rank 1 for **9/10** (the miss sat at rank 14); (c) a plumbing self-check that returns the stored text to rank 1
|
|
418
|
+
16/16. The split matters: **title-like lookups are near-perfect, verbatim-fragment lookups are not** (median rank
|
|
419
|
+
5-6), which is the price of one vector per record with the first ~500 characters embedded. Two readings that are
|
|
420
|
+
easy to get wrong: **coverage is not recall** (the article line is the last 30 date directories, 2026-09-12..10-13,
|
|
421
|
+
chat is 90 days, so a miss outside that window is a coverage gap), and **"how many records contain the anchor" is not
|
|
422
|
+
precision** - one of the ten anchors was satisfied by a "recommended reading" footer on an unrelated article.
|
|
423
|
+
|
|
424
|
+
**Per-account lookup needed an attribute filter, not vectors (same day).** The window holds 10,759 articles from
|
|
425
|
+
**413 accounts** (largest: one tabloid with 908, then 632 and 529 - the top three alone are ~19% of the window, and
|
|
426
|
+
they are general news, not air quality). Searching an account's name returns only 0-3 of the top 10 from that account,
|
|
427
|
+
because the name lives in the record's `id` (files are named `<account>-<title>.md`) and not in the embedded text -
|
|
428
|
+
the frontmatter's `source:` left the embedded text when the article body extraction was fixed. `search --from <name>`
|
|
429
|
+
now filters on that id (substring; article line only, since a chat message has no source account) and says so when an
|
|
430
|
+
account has no records instead of returning nothing. Verified on the live index: without the filter, `暗访` returns
|
|
431
|
+
0 of 5 from the tabloid; with `--from 红星新闻`, 5 of 5 are its own. It hands back the right **pool**, not a better
|
|
432
|
+
order inside it - ranking within a general-news account is still nearest-neighbour, so a topical query can return
|
|
433
|
+
that account's unrelated stories.
|
|
434
|
+
|
|
435
|
+
**A precision baseline was measured the same day, and it has to be read split - the raw number is misleading on its
|
|
436
|
+
own.** Ten fixed questions, top-5 each, every item labelled relevant / partial / irrelevant by the configured
|
|
437
|
+
DeepSeek model against one stated criterion ("does this item answer what was asked"), **not** by human label - and
|
|
438
|
+
not independently spot-checked yet beyond the author's own read of all 50. Raw strict precision@5 = 12/50 = **24%**
|
|
439
|
+
(loose, counting `partial`, 62%); after collapsing identical texts it is 12/46 = 26%, so multi-account reposts cost
|
|
440
|
+
only ~8% of the slots. The split is what matters: **content questions ("what does X say") score 12/32 = 38%**, while
|
|
441
|
+
**procedure questions ("how do I use X / where do I find Y") score 0/14**. Two of the three worst queries are
|
|
442
|
+
**coverage, not ranking**: the index contains **zero** records mentioning `HYSPLIT` (and zero with `后向轨迹`), one
|
|
443
|
+
with `在线监测`, two with `政策文件` - a corpus of 公众号 articles has no manuals in it, so no ranking change can fix
|
|
444
|
+
those. Even inside the content half the shape is the same one the recall battery showed: precision@1 is 5/10, and the
|
|
445
|
+
entries below the first are usually same-domain **neighbours** rather than answers (a monitoring question returns
|
|
446
|
+
aerosol-formation papers; an air-quality-forecast question returns wind-power and tropical-cyclone forecast papers).
|
|
447
|
+
That is the signature of one vector per record with only the first ~500 characters embedded and no rerank available.
|
|
448
|
+
|
|
449
|
+
**A repeatable harness now carries these numbers.** `scripts/recall_eval.py` builds its ground truth at run time
|
|
450
|
+
from the index itself (a uniqueness-checked 12-character fragment from the middle of a record's own text, and the
|
|
451
|
+
record's title / first 30 characters), reports the standard metric set (Recall@1/5/10, MRR@10, nDCG@10, Hit@1) plus
|
|
452
|
+
**coverage** - so a query whose answer is not in the corpus at all is reported as a coverage gap rather than counted
|
|
453
|
+
as a ranking failure - and requires `--yes` because each question spends an embedding and leaves the machine. Running
|
|
454
|
+
it immediately found a recall win: adding two gates to the keyword side of the fusion (only documents matching a rare
|
|
455
|
+
term vote, and only if their BM25 score is within 0.5x of the best; no rare term in the query means the keyword side
|
|
456
|
+
abstains) took fragment recall@10 from 0.91 to **1.00** and hit@1 from 0.60 to **0.80**, moved title hit@1 from 0.83
|
|
457
|
+
to 0.86, and left the hand-written-question battery unchanged - where the **ungated** version of the same fusion
|
|
458
|
+
measured exactly like pure vectors on all three batteries. Fusion is on by default, and **rerank is live** since the same day (it falls back to the configured chat model, since the Jev contract is region-blocked here): on the same 40 questions hit@1 0.55 -> **0.75**, recall@5 0.65 -> **0.93**, recall@10 0.75 -> **0.95** and nDCG@10 0.63 -> **0.85**; the last step of that was the rerank **candidate pool**, raised from 20 to 100 by default after the pool curve 20/50/100/200 measured recall@10 0.82 -> 0.88 -> 0.95 -> 0.95 - which clears the published production thresholds (Recall@10 >= 0.90, MRR@10 >= 0.75) - see D-112 and D-113 (the latter also corrects a "regression" in D-112 that re-measured as noise); D-111 (which
|
|
459
|
+
also corrects D-110's "harmful" conclusion: the fault was function words voting, not fusion).
|
|
460
|
+
|
|
461
|
+
**One lever is measured dead, not merely unproven: filtering the index by `relevance`.** In this window every one of
|
|
462
|
+
the 10,759 articles carries `relevance: 中` - the window is mostly backfilled days, and the backfill path deliberately
|
|
463
|
+
does no AI work, so the field holds a default rather than a judgement (see the `result` sub-scores in the quality
|
|
464
|
+
notes: `relevanceScore` only exists where the judge actually ran). It therefore has no discriminative power here and
|
|
465
|
+
must not be proposed as a precision fix without first checking that the judge ran for those days.
|
|
466
|
+
|
|
467
|
+
is what a unit test can hold. Two things about this were
|
|
366
468
|
wrong on 2026-09-27 and are now fixed: the concept directory was **declared in two places** (`VAULT_DIRS` said
|
|
367
469
|
`007_Wiki/Concepts`, every writer and reader used the top-level `Wiki/Concepts`), so `007_Wiki/` was created and
|
|
368
470
|
never filled; and the historical import skipped `Sources/` entirely (it would have carried ~33 GB of images), which
|
package/docs/RELEASING.md
CHANGED
|
@@ -262,6 +262,20 @@ npm view weflow-cli version --registry=https://registry.npmjs.org # 用官方
|
|
|
262
262
|
`release-consistency` 报「版本号与锁文件必须一致」—— 我改了 `package.json` 忘了 `package-lock.json`,
|
|
263
263
|
连红三次(含紧随其后的文档提交)。**发布物不受影响**(白名单里没有锁文件、shasum 与装出来的那份都验过),
|
|
264
264
|
但 master 的 CI 是红的;修在 `f9e55c8`,之后两次推送都是 success。清单第 1 步已补上这条检查。
|
|
265
|
+
- **1.11.1 的出处核过了(2026-10-13,发布当天,四种证据)**:这是 1.11.0 发布后**当天**的补丁版
|
|
266
|
+
(文档导出五处修复,详见 CHANGELOG)。
|
|
267
|
+
1. 本地 `npm pack` 的 shasum = `5527b538e3ed5ec49520e14e563a8bff31ee4ea4`,与**两个源**上
|
|
268
|
+
`weflow-cli@1.11.1` 的 `dist.shasum` **三者相同**;
|
|
269
|
+
2. 从 registry 装进空目录,用**装好的那份**跑 `cli.cjs --version` 得到 `1.11.1`,且它的
|
|
270
|
+
`scripts/export_chat_html.py` 与仓库里那份 **sha256 逐字节相同**(并确认含本次新增的
|
|
271
|
+
`_load_existing_manifest` / `_clean_asset_component`)——"在,而且是我这次写的那份";
|
|
272
|
+
3. 两个源都是 1.11.1:官方源**约 2 分钟**就有了,`registry.npmmirror.com` 约 **5 分钟**(`curl -X PUT …/syncs`
|
|
273
|
+
受理后);两个源上该版本的 shasum 也一致;
|
|
274
|
+
4. **版本号三处一起改**(`package.json` + `package-lock.json` 的顶层 `version` 与 `packages[""].version`),
|
|
275
|
+
所以这一次 `release-consistency` 没有再红 —— 1.11.0 那次就是漏了锁文件。
|
|
276
|
+
**tag 打在被发布的那个提交上**:`v1.11.1` → `888d8fc`(只改了 `package.json`/`package-lock.json`/CHANGELOG
|
|
277
|
+
段名,功能提交是 `2b03b4f`)。GitHub Release `v1.11.1` 指向它,正文由 `## 1.11.1` 段机械切出
|
|
278
|
+
(3,154 字符、1 条 bullet、未触及上限),建完是 **Latest**。
|
|
265
279
|
|
|
266
280
|
|
|
267
281
|
## 发布后
|
package/package.json
CHANGED
|
@@ -1,86 +1,88 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "weflow-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"weflow-cli": "cli.cjs"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"cli.cjs",
|
|
11
|
-
"dist/",
|
|
12
|
-
"scripts/**/*.py",
|
|
13
|
-
"scripts
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "weflow-cli",
|
|
3
|
+
"version": "1.13.0",
|
|
4
|
+
"description": "让 AI 读懂你的微信 — 公众号日报·聊天导出·知识库搜索·MCP Server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"weflow-cli": "cli.cjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.cjs",
|
|
11
|
+
"dist/",
|
|
12
|
+
"scripts/**/*.py",
|
|
13
|
+
"scripts/**/*.ps1",
|
|
14
|
+
"scripts/**/*.cmd",
|
|
15
|
+
"scripts/data/",
|
|
16
|
+
"mcp-server/",
|
|
17
|
+
"bin/",
|
|
18
|
+
"src/",
|
|
19
|
+
"resources/",
|
|
20
|
+
"docs/images/",
|
|
21
|
+
"docs/*.md",
|
|
22
|
+
"logo.png",
|
|
23
|
+
"README.md",
|
|
24
|
+
"OPERATIONS.md",
|
|
25
|
+
"ARCHITECTURE.md",
|
|
26
|
+
"CONTRIBUTING.md",
|
|
27
|
+
"SECURITY.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"requirements*.txt",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"wechat",
|
|
34
|
+
"weixin",
|
|
35
|
+
"mcp",
|
|
36
|
+
"rss",
|
|
37
|
+
"daily",
|
|
38
|
+
"公众号",
|
|
39
|
+
"聊天记录",
|
|
40
|
+
"ai",
|
|
41
|
+
"deepseek",
|
|
42
|
+
"claude"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/zhuobichen/weflow-cli.git"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"test": "node --import tsx --test \"test/**/*.test.ts\"",
|
|
51
|
+
"dev": "npx tsx bin/weflow-cli.ts",
|
|
52
|
+
"start": "node cli.cjs",
|
|
53
|
+
"prepublishOnly": "npm run build",
|
|
54
|
+
"eval:assistant": "node --import tsx scripts/assistant_eval.ts"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
|
+
"@wenyan-md/core": "^3.0.10",
|
|
59
|
+
"chalk": "^5.4.1",
|
|
60
|
+
"commander": "^13.1.0",
|
|
61
|
+
"css-tree": "^3.2.1",
|
|
62
|
+
"exceljs": "^4.4.0",
|
|
63
|
+
"form-data-encoder": "^4.1.0",
|
|
64
|
+
"formdata-node": "^6.0.3",
|
|
65
|
+
"front-matter": "^4.0.2",
|
|
66
|
+
"fzstd": "^0.1.1",
|
|
67
|
+
"highlight.js": "^11.11.1",
|
|
68
|
+
"inquirer": "^13.4.3",
|
|
69
|
+
"jsdom": "^27.4.0",
|
|
70
|
+
"koffi": "^2.9.0",
|
|
71
|
+
"marked": "^18.0.4",
|
|
72
|
+
"marked-highlight": "^2.2.4",
|
|
73
|
+
"qrcode-terminal": "^0.12.0"
|
|
74
|
+
},
|
|
75
|
+
"optionalDependencies": {
|
|
76
|
+
"lz4": "^0.6.5"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@types/node": "^22.0.0",
|
|
80
|
+
"@types/qrcode-terminal": "^0.12.2",
|
|
81
|
+
"electron": "^42.0.1",
|
|
82
|
+
"tsx": "^4.19.0",
|
|
83
|
+
"typescript": "^5.7.0"
|
|
84
|
+
},
|
|
85
|
+
"engines": {
|
|
86
|
+
"node": ">=22.13.0"
|
|
87
|
+
}
|
|
88
|
+
}
|