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/docs/DECISIONS.md
CHANGED
|
@@ -294,6 +294,1430 @@ The gaps being closed - shard read failures dropped silently, media counters com
|
|
|
294
294
|
- Still not advertised: a stable incremental cursor. D-027 stands - overlapping windows plus local
|
|
295
295
|
deduplication is what this exposes.
|
|
296
296
|
|
|
297
|
+
## D-030: Adapt message reads to the shard's actual columns, and refuse a window rather than approximate it
|
|
298
|
+
|
|
299
|
+
**Status:** Active
|
|
300
|
+
|
|
301
|
+
Three changes to message reads, all about a read that cannot be done as asked.
|
|
302
|
+
|
|
303
|
+
1. The SELECT is built per shard from `PRAGMA table_info` against the six columns the reader
|
|
304
|
+
actually consumes (`local_id`, `server_id`, `local_type`, `real_sender_id`, `create_time`,
|
|
305
|
+
`message_content`). The previous SELECT named fourteen columns while `_message_dict` read six,
|
|
306
|
+
so a version renaming any of the other eight made every shard raise and the conversation came
|
|
307
|
+
back **empty with no error** unless `--report-shards` was used. A shard missing one of the six is
|
|
308
|
+
now read anyway, with the loss named in `missingColumns`; a shard where none of the three anchor
|
|
309
|
+
columns (`create_time`, `local_id`, `server_id`) survives is reported `SCHEMA_MISMATCH`.
|
|
310
|
+
2. `messages` gains `--from`/`--to` (unix seconds, inclusive) pushed into SQL. A shard whose table
|
|
311
|
+
has no `create_time` is reported `WINDOW_UNAVAILABLE` and skipped rather than returning
|
|
312
|
+
out-of-range rows.
|
|
313
|
+
3. `export_chat_html.fetch_messages` keeps its ten-column positional projection, but a column the
|
|
314
|
+
shard does not have is selected as `NULL AS <name>` instead of being dropped. The rest of that
|
|
315
|
+
file reads rows by index (`row[4]` is the sender, `row[5]` the time), so dropping a column would
|
|
316
|
+
shift every later field by one. A `--date` export on a shard without `create_time` raises rather
|
|
317
|
+
than widening itself to the whole conversation.
|
|
318
|
+
|
|
319
|
+
**Reason:** A read failure that is indistinguishable from "nothing here" is the worst failure mode
|
|
320
|
+
this codebase has had - it cost a real investigation into an allegedly truncated export. Columns
|
|
321
|
+
nobody reads must not be able to cause one, and in the exporter a shifted column would be worse
|
|
322
|
+
still: silently wrong output that no error surfaces. For the window, the caller of a windowed read is
|
|
323
|
+
tracking what it has covered: returning rows it did not ask for lets it record a range it never
|
|
324
|
+
read, and silently returning fewer rows is the same failure in the other direction. Refusing is the
|
|
325
|
+
only answer that stays honest, and it is visible because every in-tree window caller also passes
|
|
326
|
+
`shard_report`.
|
|
327
|
+
|
|
328
|
+
**Consequences:**
|
|
329
|
+
|
|
330
|
+
- `coverage: 'partial'` now means "an opened shard carries a `reason`", not "a shard reported
|
|
331
|
+
`READ_FAILED`". `SCHEMA_MISMATCH` and `WINDOW_UNAVAILABLE` join it: all three describe rows that
|
|
332
|
+
were not read. A shard that never opened still yields `unverified`, unchanged - nothing is known
|
|
333
|
+
about its rows, which is a different claim from knowing they were missed.
|
|
334
|
+
- `missingColumns` is **not** a coverage failure. The rows came back; only fields were lost. It is
|
|
335
|
+
reported as a `shard-columns-missing:<shard>:<columns>` warning and leaves `coverage: complete`.
|
|
336
|
+
Treating the two alike would make every sync on such a database report `partial` forever and never
|
|
337
|
+
advance `lastSuccessfulRun`.
|
|
338
|
+
- The window pushdown is an **optimisation, never the correctness guarantee**. `syncService` still
|
|
339
|
+
filters by the window itself, because the non-NT backends have no pushdown. It measured no faster
|
|
340
|
+
(519 ms with no window vs 514 ms from the newest message on a 925-message conversation): the cost
|
|
341
|
+
is process start plus 256000-round PBKDF2 per shard, not row transfer. Do not advertise it as a
|
|
342
|
+
speed-up.
|
|
343
|
+
- The `--from`/`--to` pushdown is a prerequisite for a stable cursor, not a cursor. D-027 stands.
|
|
344
|
+
- Unknown columns are ignored rather than selected, so a future schema needs no code change to stay
|
|
345
|
+
readable - but no future schema has been tested. Synthetic shards in
|
|
346
|
+
`test/nt_decrypt_shards_test.py` are the only evidence.
|
|
347
|
+
- The exporter keeps its own copy of both the shard discovery and the projection. They were **not**
|
|
348
|
+
unified with `nt_decrypt` here; only the failure mode was fixed on both sides. The two copies are
|
|
349
|
+
pinned together by a test, so the next person to change one is told about the other.
|
|
350
|
+
|
|
351
|
+
## D-031: Judgement goes to a decision model, generation stays with the LLM
|
|
352
|
+
|
|
353
|
+
**Status:** Active
|
|
354
|
+
|
|
355
|
+
The daily pipeline's article **topic** (6-way) and **relevance** (3-level) are now decided by
|
|
356
|
+
TypeSafe's Jev (`POST https://api.typesafe.ai/v1/systemone`, `scripts/jev_client.py`), a model that
|
|
357
|
+
returns typed answers with probabilities instead of text. The LLM still **generates** the summary,
|
|
358
|
+
tags and concepts - that is what it is for, and Jev cannot generate at all. When no TypeSafe key is
|
|
359
|
+
configured, the previous "prompt for a format then parse it with two regexes" path runs unchanged.
|
|
360
|
+
|
|
361
|
+
**Reason:** the old path was not merely inaccurate, it was inert. Measured over the 2201 stored
|
|
362
|
+
articles, `relevance` is the default `中` in **2199** of them - and the code says why:
|
|
363
|
+
`biz_daily.py` assigned it on only two of five paths, the `category_hint` path hard-coded `'中'`,
|
|
364
|
+
and the exception and short-content paths never assigned it at all, leaving it to the writer's
|
|
365
|
+
`fm.get('relevance', '中')`. So `generate_ai_report.py:123`'s gate
|
|
366
|
+
(`if topic != FOCUS_TOPIC and relevance != '高': continue`) had only ever filtered on topic;
|
|
367
|
+
the "relevance" dimension had never once admitted an article. **That sentence was itself too
|
|
368
|
+
generous, and a later check corrected it**: the gate lived only in the markdown-scan
|
|
369
|
+
*fallback* loader. The primary loader (`.articles.json`, which exists on every normal run)
|
|
370
|
+
returned every article unfiltered, so the documented rule was not being applied at all. Topic fared little better: it is
|
|
371
|
+
`学术` in **zero** articles on a normal day and in **100%** of them on 2026-09-04/05.
|
|
372
|
+
|
|
373
|
+
*That last attribution was wrong, and the data said so.* It called 09-04 the signature of the
|
|
374
|
+
`except` branch's `topic = source_category or '学术'` fallback - but that branch would have left
|
|
375
|
+
`topic: 学术` and `tags: ['学术']` **in the JSON**, and 09-04's JSON has neither key populated
|
|
376
|
+
(`""` and `[]` for all 178 articles). Article dicts with no `topic` and no `tags` at all mean the
|
|
377
|
+
classification phase never ran: no API key, or `--no-ai`. The `学术` a reader sees on that day
|
|
378
|
+
comes from the *write* path - the grouping step's default and the md frontmatter's `tags`
|
|
379
|
+
default - which is precisely the divergence the follow-up pass fixed (see below).
|
|
380
|
+
|
|
381
|
+
A 60-article comparison was run before switching (`scripts/jev_probe.py`, stratified across topics
|
|
382
|
+
and days, sent state = title + body only): agreement with the stored labels was 58.6%, and the
|
|
383
|
+
disagreements ran **against** the stored labels almost everywhere - `习近平向第八届中俄能源商务论坛
|
|
384
|
+
致贺信` stored as 学术 against Jev's 政治, a `人民日报·夜读` cooking essay stored as 政治 against
|
|
385
|
+
Jev's 文学 (the body was read to confirm), a `Nature Climate Change` paper stored as AI against
|
|
386
|
+
Jev's 学术. **This is not an accuracy measurement** - the labels are the DeepSeek output, not a gold
|
|
387
|
+
standard - which is exactly why the switch is reversible and why the raw score is kept.
|
|
388
|
+
|
|
389
|
+
**Consequences:**
|
|
390
|
+
|
|
391
|
+
- `--classifier {auto,llm,jev}` (default `auto`) keeps one-flag rollback and lets anyone re-run a
|
|
392
|
+
date both ways. `auto` means "Jev if a key is configured, otherwise the old path".
|
|
393
|
+
- **The LLM prompt is deliberately unchanged.** It still asks for `【主题】`/`【相关度】` that we now
|
|
394
|
+
ignore. That keeps the fallback *byte-identical to the old behaviour* rather than a new, worse
|
|
395
|
+
fallback; the cost is roughly 20 wasted output tokens per article. Slimming the prompt is a
|
|
396
|
+
follow-up, not this change.
|
|
397
|
+
- Two **additive** frontmatter keys: `relevanceScore` (raw) and `topicConfidence` (0-1). Downstream
|
|
398
|
+
compares `relevance` as a literal string and ignores unknown keys. Without these the probability -
|
|
399
|
+
the entire new information - would be discarded at the write step.
|
|
400
|
+
- The three-level cut points (`<0.5` 低, `<1.5` 中, else 高) are **provisional and uncalibrated**.
|
|
401
|
+
They are derived from the zero-indexed score scale, and the raw score is stored so recalibrating
|
|
402
|
+
does not require re-running a day's report.
|
|
403
|
+
- **Classification runs before the generation loop, concurrently.** The two stages have no
|
|
404
|
+
dependency in either direction, so their ordering was only ever historical. Measured: 12
|
|
405
|
+
real articles in 3.3s at 6 workers against ~12s one at a time. The dependency that *does*
|
|
406
|
+
exist is positional - `decisions[k]` must belong to `articles[k]` - so the eligibility test
|
|
407
|
+
in the concurrent stage is kept character-for-character identical to the loop's, and any
|
|
408
|
+
article that fails still occupies its own key with a `None` rather than being absent (an
|
|
409
|
+
absent key would let the loop treat a neighbour's judgement as its own). Concurrency is
|
|
410
|
+
kept at 6 deliberately: the service is new enough to return `529` under load, and raising
|
|
411
|
+
the fan-out buys retries rather than throughput.
|
|
412
|
+
- The client is **fail-loud** (`JevError` with the HTTP status and a 400-character truncated body,
|
|
413
|
+
never the key); the caller is **fail-soft** (per article, printing a WARN and falling back). One
|
|
414
|
+
unclassifiable article must not abort a day's report - but a client must not disguise a failure
|
|
415
|
+
as a plausible-looking answer either.
|
|
416
|
+
- **New data egress**: article titles and bodies now also go to `api.typesafe.ai`. Article text
|
|
417
|
+
already went to DeepSeek for summarisation, so this is a new vendor rather than a new category,
|
|
418
|
+
but it is a vendor that did not exist before 2026-09-15. Configuring `typesafeApiKey` is the
|
|
419
|
+
opt-in; `daily --no-ai` remains the way to keep everything local.
|
|
420
|
+
- The key is a first-class config field (`typesafeApiKey`, in `ENCRYPTED_KEYS`, settable via
|
|
421
|
+
`config set`), and Python reads it through `_utils.get_typesafe_key()`. That function returns an
|
|
422
|
+
empty string rather than raising when the ciphertext cannot be decrypted - `configService`'s
|
|
423
|
+
`lockDecrypt()` silently returns `''` in the same situation, and a config copied from another
|
|
424
|
+
machine must degrade to the old path rather than crash the daily run.
|
|
425
|
+
- **The taxonomy is one table, and both paths read it.** `TOPICS` had been copied into six
|
|
426
|
+
files and `TOPIC_CRITERIA` lived separately inside `jev_client`; both now sit in `_utils` and
|
|
427
|
+
every consumer imports them. The trigger was a concrete contradiction: `TOPIC_PROMPT`
|
|
428
|
+
generated its "must be one of" line from `TOPICS` (six categories, including 政治) while
|
|
429
|
+
hardcoding **five** in its two reminders and omitting 政治 from the judging rules - a category
|
|
430
|
+
holding 26% of the corpus had no definition, in a prompt whose whole job is to be strict about
|
|
431
|
+
the list. Prose enumerations drift; a table does not. The criteria table is shared rather than
|
|
432
|
+
mirrored precisely so that "falling back to the LLM path" means judging by the same standards
|
|
433
|
+
it always did. Pinned by identity in `test/topic_taxonomy_test.py`, because a copy that happens
|
|
434
|
+
to be correct passes an equality check.
|
|
435
|
+
- **The report's admission question is now asked directly.** `worth_including` (a `noul`) rides
|
|
436
|
+
along in the same request - measured at 0.91s for 12 questions versus 0.84s for 2, with `state`
|
|
437
|
+
dominating the tokens, so the marginal question is essentially free - and lands in frontmatter as
|
|
438
|
+
`includeScore`. The gate used to be `relevance != '高'`, which asks "how useful is this to the
|
|
439
|
+
reader" and then reads the answer as "put it in today's report". Those are different questions and
|
|
440
|
+
no threshold tuning can reconcile them. The two scores do diverge in practice: an award
|
|
441
|
+
announcement scored `relevance` 0.79 but `includeScore` 0.03 (related to the field, nothing to
|
|
442
|
+
use today), and two engineering posts landed at 0.48/0.63 - close enough to the cut point to show
|
|
443
|
+
the probability is not saturated at the ends.
|
|
444
|
+
- **Both loaders now share one predicate** (`admits()`), with `--include-all` to revert to
|
|
445
|
+
collecting everything. This is a real behaviour change on the primary path: the report will
|
|
446
|
+
contain fewer non-focus articles than before, because before it contained *all* of them. Articles
|
|
447
|
+
written before `includeScore` existed fall back to the old `relevance == '高'` rule, so
|
|
448
|
+
regenerating an old date does not silently swap its article set.
|
|
449
|
+
- An unused question is worse than no question. `is_research_paper` was added in the first cut of
|
|
450
|
+
this change and never read by anything - the same "compute it and throw it away" shape as the
|
|
451
|
+
exporter's old `COVER_STATE` counters. It was removed and replaced by `worth_including`.
|
|
452
|
+
- **Not done, deliberately**: the other eight "ask the LLM then parse the text" call sites (assistant
|
|
453
|
+
tool routing, long-term memory extraction, todo urgency, monthly-report task detection, ...).
|
|
454
|
+
That group was never measured on Chinese, so switching it would be a bet rather than a change.
|
|
455
|
+
- **Superseded by later work, kept so the reasoning stays readable**: this decision listed `tags` and
|
|
456
|
+
"`TOPICS` duplicated across five files" as deliberately-not-done. `TOPICS` and `TOPIC_CRITERIA` are
|
|
457
|
+
now single-sourced in `_utils` (the prompt path and the decision-model path read one table), and
|
|
458
|
+
`test/topic_taxonomy_test.py` pins every consumer by identity. The four `TOPIC_ORDER` copies in
|
|
459
|
+
`auto_tag` / `create_reading_notes` / `enrich_backlinks` / `generate_html` remain, because two of
|
|
460
|
+
those files have no `_utils` import edge and whether an import resolves would then depend on the
|
|
461
|
+
caller's working directory - so a test asserts the four copies still equal `TOPICS` instead, turning
|
|
462
|
+
a silent future drift (adding a seventh category would drop a section from the report) into a
|
|
463
|
+
failure.
|
|
464
|
+
- **A follow-up pass found the root cause behind the 2199-of-2201 number and fixed it.** The default
|
|
465
|
+
was not merely applied too often - it was applied **inconsistently by the two writers**: the JSON
|
|
466
|
+
writer defaulted a missing topic to `''` and the grouping step that names the folder and writes the
|
|
467
|
+
md frontmatter defaulted it to `学术`, seven lines apart, so a single run could emit `topic: 学术` in
|
|
468
|
+
the md and `topic: ""` in the JSON with nothing reported either way. The 2026-09-04 output is that
|
|
469
|
+
failure in full (178 articles, all under `学术/` in the md, all `""` in the JSON, two of them
|
|
470
|
+
"OpenAI 深夜发布 GPT-6 Astra"). The reachable trigger is ordinary: `daily --no-ai`, or a run with
|
|
471
|
+
no API key, skips classification entirely, leaving every article without a `topic` key while the
|
|
472
|
+
write phase still runs. Both writers now read one normalised value (`_utils.DEFAULT_TOPIC`, applied
|
|
473
|
+
by `biz_daily._group_by_topic`), the check is **membership in `TOPICS`** rather than emptiness, and
|
|
474
|
+
the run prints how many articles fell back so a whole-batch fallback cannot read as a normal
|
|
475
|
+
classification. The same shape sat in `tags` and was fixed the same way.
|
|
476
|
+
|
|
477
|
+
**The `tags` question this decision left open is now answered, and the answer is "not a bug".**
|
|
478
|
+
Measured over the 1584 articles across the 11 stored days: 1396 carry exactly `[topic]`, 182 carry
|
|
479
|
+
none, and **6 carry real tags** - and those 6 are the whole corpus built without configured source
|
|
480
|
+
categories. The mechanism is deliberate, in the prompt: when a source has a configured category,
|
|
481
|
+
`biz_daily` sends a summary-only prompt that says *不要输出主题、标签、相关度或概念字段* and sets
|
|
482
|
+
`tags = [category]` itself. Every source in those runs had a configured category, so no article
|
|
483
|
+
ever reached the path that asks for tags. The signature is unambiguous and checkable: **on all 11
|
|
484
|
+
days every source maps to exactly one topic** (36/40/15/43/41/27/35/36/3/36/6 sources, zero
|
|
485
|
+
exceptions) - which is what topic-from-config looks like and per-article classification does not.
|
|
486
|
+
A live call through the production prompt on one stored article returns
|
|
487
|
+
`AI与数学, 人类数学家, 数学共同体, 人机关系, 学术人文`, so the extraction works whenever that path is
|
|
488
|
+
used. Two consequences worth keeping: real tags appear only for sources *without* a configured
|
|
489
|
+
category, and the earlier "tags never persist" reading was wrong - it described the
|
|
490
|
+
configured-category path, not a defect.
|
|
491
|
+
- **Contract hardening, added later and verified against the live API** rather than
|
|
492
|
+
against a source reading. Two capabilities this client was not using: `instructions`
|
|
493
|
+
accepts a **structured object** (`{"goal": …, "rules": […]}`) as well as a string, and
|
|
494
|
+
every response carries a **`model` field naming the served version** (`jev-1.13.0`) as
|
|
495
|
+
against the requested alias (`jev-latest`). The served name is now recorded
|
|
496
|
+
(`usage['model']`, `JevClient.last_model`, and a `decisionModel` key in the daily's
|
|
497
|
+
`.articles.json`) because an alias can drift while every result still looks correct -
|
|
498
|
+
the same shape as the bug this decision's own history keeps producing: a plausible
|
|
499
|
+
value that is silently not the one you think. Every `choice` answer is now validated
|
|
500
|
+
before use (probabilities present, key set equal to the criteria, values in `[0,1]`,
|
|
501
|
+
sum ≈ 1, `choice` == argmax); the argmax rule is the load-bearing one, since a
|
|
502
|
+
non-argmax choice looks exactly like a normal answer. The daily's prompts stay as
|
|
503
|
+
strings: switching them to the object form would change model behaviour, and the
|
|
504
|
+
existing cut points were calibrated against the string form.
|
|
505
|
+
|
|
506
|
+
## D-032: Use the decision model as a reranker - one request per pass
|
|
507
|
+
|
|
508
|
+
**Status:** Active
|
|
509
|
+
|
|
510
|
+
`semantic_search.rerank()` takes the top 20 hits from the first-stage search, asks one
|
|
511
|
+
`noul` question per candidate in a **single** request, and reorders by the returned
|
|
512
|
+
probabilities. `search` calls it before slicing to `top_k`; `--no-rerank` restores the
|
|
513
|
+
previous behaviour exactly.
|
|
514
|
+
|
|
515
|
+
**Reason:** the retrieval stage had no second pass at all. `search()` scored by cosine
|
|
516
|
+
similarity and took `argsort[:top_k]` - one line, no reranking. Embeddings answer "is this
|
|
517
|
+
semantically near the query", not "does this actually answer it", and the keyword fallback
|
|
518
|
+
(used whenever embeddings are unavailable or return zero vectors) answers something cruder
|
|
519
|
+
still. Reranking is the purest form of what this model does, and the cost model makes it
|
|
520
|
+
viable: measured 0.84s for 2 questions against 0.91s for 12, since `state` dominates the
|
|
521
|
+
token count. **One pass over 20 candidates is ~1 second, not 20 round trips.**
|
|
522
|
+
|
|
523
|
+
**Consequences:**
|
|
524
|
+
|
|
525
|
+
- **Numbering is the hazard.** If question `c3` gets answered against candidate `c5`, the
|
|
526
|
+
most relevant hit sinks to the bottom and the result looks like an ordinary "the model
|
|
527
|
+
thought it was irrelevant" outcome. Nothing errors. So every candidate carries an
|
|
528
|
+
explicit `【候选k】` label in the `state`, and the request also asks a `choice` for
|
|
529
|
+
"which candidate best answers this" purely as a **cross-check**: if the single choice
|
|
530
|
+
disagrees with the argmax of the per-candidate scores, a warning is printed. The check
|
|
531
|
+
only warns - it does not reorder, because one wrong answer should not be able to swap
|
|
532
|
+
the whole list.
|
|
533
|
+
- Verified live before shipping: the relevant article was planted at position 4 of 8 and
|
|
534
|
+
came out first at 0.94 against 0.01-0.04 for the rest, with the self-check agreeing.
|
|
535
|
+
- `score` keeps its meaning (cosine similarity or keyword count) and the new value goes to
|
|
536
|
+
`rerankScore` - the same additive-key rule as `relevanceScore` and `includeScore`.
|
|
537
|
+
- A candidate the model did not score is ordered **after** the scored ones and carries no
|
|
538
|
+
`rerankScore`: "unknown" and "irrelevant" are different answers, and collapsing them
|
|
539
|
+
would silently reorder on a partial response.
|
|
540
|
+
- Fail-soft: no client, fewer than two candidates, or any error returns the original order.
|
|
541
|
+
Reranking is an improvement to search, not a precondition for it.
|
|
542
|
+
- **Transient failures are retried.** The client got a live `HTTP 529 system_overloaded`
|
|
543
|
+
while this was being built; TypeSafe's own reference implementation retries transient
|
|
544
|
+
provider failures twice by default. `JevClient` now retries 429/5xx/529 with a
|
|
545
|
+
deliberately short backoff (worst case ~2.4s), because the daily pipeline calls it once
|
|
546
|
+
per article and a long backoff would stretch an already overloaded run into tens of
|
|
547
|
+
minutes - time the caller should be spending on its fallback path.
|
|
548
|
+
|
|
549
|
+
## D-033: Every machine judgement shown to a person carries its evidence
|
|
550
|
+
|
|
551
|
+
**Status:** Active
|
|
552
|
+
|
|
553
|
+
`scripts/reply_debt.py` asks, once per conversation, whether the thread is sitting on a reply
|
|
554
|
+
the user owes (`waiting`), how urgent it is, whether a promise is outstanding, whether money or
|
|
555
|
+
delivery is involved, and what kind of conversation it is. Results are ranked and printed with
|
|
556
|
+
their probabilities. Two rules came out of building it, and both are general:
|
|
557
|
+
|
|
558
|
+
1. **A message with no text must be labelled, not left blank.** WeChat stores images, voice
|
|
559
|
+
notes, videos and stickers with an empty `message_content`. Unlabelled, they entered the
|
|
560
|
+
state as a line reading `对方:` with nothing after it - and the model returned a confident
|
|
561
|
+
`0.48` for "is this person waiting on me" **from an empty line**. Every such message now
|
|
562
|
+
carries its type (`[图片]` / `[语音]` / `[非文本 localType=N]`). Measured effect: that same
|
|
563
|
+
conversation dropped to `0.40` and moved out of the result set. A model asked to judge
|
|
564
|
+
nothing will still answer; supplying the type is what stops it.
|
|
565
|
+
2. **A judgement shown to a human must carry how thin its evidence was.** "Waiting 0.69" derived
|
|
566
|
+
from a two-character last message is not the same claim as one derived from a full
|
|
567
|
+
explanation, and the model cannot tell the difference - it only sees text. So each row prints
|
|
568
|
+
`证据:对方末条 N 字 · 对方实质发言 M 条`, and anything resting on fewer than five characters
|
|
569
|
+
is marked as too thin to act on.
|
|
570
|
+
|
|
571
|
+
**The report now states its own uncertainty.** `generate_ai_report.py` appends a `我拿不准的`
|
|
572
|
+
section listing entries whose `includeScore` sits inside the band (both admitted and excluded)
|
|
573
|
+
plus those whose `topicConfidence` is low enough that the section they were filed under may be
|
|
574
|
+
wrong. It is computed locally from frontmatter - asking a model to describe its own uncertainty
|
|
575
|
+
is asking it to generate more prose, which is the failure this whole line of work exists to
|
|
576
|
+
avoid. When the articles predate the probability fields, the section says so explicitly: an
|
|
577
|
+
absent section must not be readable as "everything here was certain".
|
|
578
|
+
|
|
579
|
+
**Measured prevalence, which is what the threshold actually trades against.** Over a
|
|
580
|
+
topic-balanced 160-article candidate pool: **86.1%** of articles score below 0.2 on
|
|
581
|
+
`includeScore`, 4.4% in 0.20-0.35, 0.6% in 0.35-0.50, and **8.9% at or above 0.50** (3.8% +
|
|
582
|
+
1.9% + 3.2%). So the 0.5 threshold admits roughly one article in eleven of a balanced mix -
|
|
583
|
+
and the real daily mix is 新闻-heavy, which scores low, so the true figure is likely lower.
|
|
584
|
+
An earlier guess in this document ("about 3%") was not derived from anything; this one is.
|
|
585
|
+
The sampler also had to change to produce a usable curve: sampling by topic alone yielded a
|
|
586
|
+
50-article sample in which 45 items sat below 0.2, so labelling 50 bought the information of
|
|
587
|
+
labelling 4. It now draws from a larger scored pool, stratified **by probability band**, which
|
|
588
|
+
put 22 of 50 in the bands that matter - and reports the pool's band shares separately, because
|
|
589
|
+
a band-stratified sample is not a natural distribution and must not be read as one.
|
|
590
|
+
|
|
591
|
+
**How the "no gold standard" gap finally gets closed.** `scripts/quality_eval.py sample`
|
|
592
|
+
draws a stratified sample and scores each article through the **production** path at sample
|
|
593
|
+
time, then writes blank `topic` / `include` fields for a person to fill in; `score` prints
|
|
594
|
+
topic agreement (for Jev *and* for the stored labels), a calibration table by probability
|
|
595
|
+
bucket, and a threshold sweep naming the cut point that best matches the human. Two design
|
|
596
|
+
points are load-bearing:
|
|
597
|
+
|
|
598
|
+
- **The sample is re-scored instead of reusing stored values.** No real artifact on disk
|
|
599
|
+
carries `includeScore` - the six files in the 2026-09-05 output directory were written by a
|
|
600
|
+
run of the code from *before* that field existed, so they have `relevanceScore` and
|
|
601
|
+
`topicConfidence` only. And the stored `topic` is the very thing under test: it labelled 364
|
|
602
|
+
articles as 学术 in one day. So the (probability, human label) pairs have to be produced
|
|
603
|
+
fresh, which also means the corpus never has to be re-run and `output/` is only read.
|
|
604
|
+
- **A blank label is "not sure", not "wrong".** Items left `null` are excluded from every
|
|
605
|
+
number rather than counted as misses, because collapsing the two would make accuracy look
|
|
606
|
+
worse the more honest the labeller is.
|
|
607
|
+
|
|
608
|
+
**A field written on one path and not the other is a field that does not exist.** The three
|
|
609
|
+
probability fields were added to the markdown frontmatter but not to `.articles.json`, and the
|
|
610
|
+
report loader prefers the JSON. The new section therefore worked in the fallback path and was
|
|
611
|
+
dead on the primary one - which is how it was found, by running a real dry-run and watching it
|
|
612
|
+
report "no probability fields" while the frontmatter plainly carried them. The same shape has
|
|
613
|
+
now appeared five times in this codebase: two loaders, two shard-discovery copies, argv-vs-env
|
|
614
|
+
secrets, the admission gate living only in the fallback, and now serialization. **When adding a
|
|
615
|
+
field whose value is consumed by another script, add it to every representation and test the one
|
|
616
|
+
the consumer actually reads.**
|
|
617
|
+
|
|
618
|
+
**Measured: what you feed sets the ceiling, not the model.** The same 132 questions (44 scripts x
|
|
619
|
+
does-it-write / does-it-network / does-it-spawn) were asked twice, changing only the evidence in
|
|
620
|
+
the state. Given each file's first 14 lines: **77.3%** agreement with a regex baseline. Given its
|
|
621
|
+
first 1500 characters: **88.6%**, with subprocess at **100%** and file-writing at 97.7%, for 2.3s
|
|
622
|
+
and $0.0011. The ceiling moved 11 points without touching a single question.
|
|
623
|
+
|
|
624
|
+
**And the disagreements are not automatically the model's fault.** Of the remaining `network`
|
|
625
|
+
mismatches, five of the six checked by hand call `call_ai` / `create_engine`, which reaches
|
|
626
|
+
`urllib.request.urlopen` inside `_utils` - so those scripts *do* egress and the model was right
|
|
627
|
+
while the regex baseline (which only looked inside each file) was wrong. **Two imperfect
|
|
628
|
+
instruments agreeing 88.6% of the time is not an accuracy figure for either of them.** Treating
|
|
629
|
+
the baseline as truth would have produced a confidently wrong conclusion about which instrument
|
|
630
|
+
to trust.
|
|
631
|
+
|
|
632
|
+
**Also:** `waiting` has one mechanically checkable failure mode - claiming the other side is
|
|
633
|
+
waiting while the last message in the transcript is the user's own. That contradiction is
|
|
634
|
+
detected and reported. It is the only part of this output that can be falsified without reading
|
|
635
|
+
the messages by hand, which is exactly why it is worth checking.
|
|
636
|
+
|
|
637
|
+
**Honest limitations:** there is no gold standard, so every number is a prompt, not a fact. On
|
|
638
|
+
the machine this was developed on the tool reports **two** candidates at 0.54 and 0.62 out of 25
|
|
639
|
+
recent conversations, with nine more between 0.30 and 0.45 marked uncertain and three filtered
|
|
640
|
+
out as customer-service or marketing - a modest result, and the right one for someone who
|
|
641
|
+
answers promptly. It is not evidence that the model would be accurate on someone else's data.
|
|
642
|
+
Debt age is measured from the **other side's** last message, not the conversation's last
|
|
643
|
+
activity: if the user replied most recently the debt is zero, and using session activity would
|
|
644
|
+
have flattened exactly the case worth surfacing.
|
|
645
|
+
|
|
646
|
+
## D-034: Expose the decision model as a local primitive, but not over MCP
|
|
647
|
+
|
|
648
|
+
**Status:** Active
|
|
649
|
+
|
|
650
|
+
`weflow-cli decide --request <file>` (and `scripts/decide.py`, which also reads stdin) takes a
|
|
651
|
+
caller-supplied `{state, questions}` and returns `{answers, usage, costUsd}` from one decision
|
|
652
|
+
request. The command reads **no local data of its own** - the entire state is whatever the
|
|
653
|
+
caller hands it.
|
|
654
|
+
|
|
655
|
+
**Reason:** the value is not that this judges better than the caller's own model; it is that a
|
|
656
|
+
batch of judgements becomes affordable. Measured: ~1s for a request regardless of whether it
|
|
657
|
+
carries 2 questions or 12, since `state` dominates the token count, and 20 candidates cost
|
|
658
|
+
around two ten-thousandths of a cent more than 1. **That ~1s is an idle-service figure.** It is
|
|
659
|
+
independent of state size (200 characters and 4000 characters both measured ~1.1s), but not of
|
|
660
|
+
concurrency: a 158-article pass at 6 workers averaged **5.7s per call**, with a 10.1s outlier.
|
|
661
|
+
The cost model is still what makes batch judging viable - it just means wall-clock for a large
|
|
662
|
+
batch should be estimated at seconds per call, not at one. So "label 200 items across six dimensions"
|
|
663
|
+
stops being a token-budget decision. Two further properties come from it being non-generative:
|
|
664
|
+
the answers arrive **typed with probabilities** rather than as prose to be parsed, and nothing
|
|
665
|
+
is being *written*, so it is safe to place inside control flow where generated text would be
|
|
666
|
+
unwanted.
|
|
667
|
+
|
|
668
|
+
**Consequences:**
|
|
669
|
+
|
|
670
|
+
- **Batch mode exists because the primitive was unusable without it.** Judging 44 files across
|
|
671
|
+
3 questions by hand meant writing 132 question blocks; the first real use of `decide` was a
|
|
672
|
+
throwaway loop that built them. `--over <glob> --ask <text>` now does that expansion, and the
|
|
673
|
+
response carries a `batch` mapping from question name to file and question, so a caller never
|
|
674
|
+
has to parse names - file names contain spaces, `|` and CJK punctuation, and encoding them into
|
|
675
|
+
question names would make every reader a string-parsing exercise.
|
|
676
|
+
- **The evidence size is an argument, and it is reported back.** `--max-chars` defaults to 1500
|
|
677
|
+
and the response echoes it, because the measurement above says the evidence you feed sets the
|
|
678
|
+
ceiling. A tool that decides that number silently would invite the conclusion that its output
|
|
679
|
+
means more than it does.
|
|
680
|
+
- **Requests are validated locally.** The service returns `422` for a malformed request, but
|
|
681
|
+
that error can only say which field is invalid - not what the caller meant. Local validation
|
|
682
|
+
names the intent: "a `score` needs at least two ordered levels", "a `choice` needs a non-empty
|
|
683
|
+
criteria object", "a `score` with one level is a constant zero and carries no information".
|
|
684
|
+
- **Deliberately not exposed over MCP this round.** An MCP client - explicitly a separate trust
|
|
685
|
+
boundary per D-002 - would be able to drive local outbound calls carrying arbitrary text of
|
|
686
|
+
its choosing. That is a new egress surface and it needs its own decision, not a side effect of
|
|
687
|
+
adding a convenience tool. `capabilities --json` records `mcpExposed: false` with the reason,
|
|
688
|
+
so the omission is visible rather than inferred.
|
|
689
|
+
- Follows the `search`/`awaiting` discipline: `--dry-run` validates and echoes the shape with
|
|
690
|
+
**no egress**, `--yes` runs, and neither given means confirm. `--dry-run` needs no key, on the
|
|
691
|
+
same reasoning as `awaiting`: a preview exists to answer "is this worth spending?".
|
|
692
|
+
- Honest limitation: this is a *different* model, not a strictly better one, and there is no gold
|
|
693
|
+
standard behind its numbers. For a single one-off judgement the caller's own model is fine and
|
|
694
|
+
this is just an extra hop. It pays off at scale, where the alternative is many calls and prose
|
|
695
|
+
to parse.
|
|
696
|
+
|
|
697
|
+
## D-035: The assistant's single-round fast path: default-off, and how it got there
|
|
698
|
+
|
|
699
|
+
**Status:** Active (shipped 2026-09-22 behind a default-off switch; see the update at the end)
|
|
700
|
+
|
|
701
|
+
The assistant's ReAct loop costs two LLM round-trips for the common "answer from local data"
|
|
702
|
+
question: round 1 picks a tool and its arguments, round 2 phrases the reply from the result. A
|
|
703
|
+
decision-model pre-router could resolve `{needs_tool, tool, closed-set arguments}` in one ~1s
|
|
704
|
+
call before the loop, execute that tool, and let the very first `callLLM` see the result - turning
|
|
705
|
+
two round-trips into one. Measured elsewhere in this repo, the call model supports it: 12
|
|
706
|
+
questions cost 0.91s against 0.84s for two, so the routing decision is nearly free.
|
|
707
|
+
|
|
708
|
+
**Why it is not implemented here:** `handleMessage` is a privacy-audited, daemon-resident path.
|
|
709
|
+
It carries the access-control gate, `privacyGate.audit` on every tool call, the daily limit, and
|
|
710
|
+
three-tier memory - and it can only be exercised end to end against a **live** WeChat channel
|
|
711
|
+
(bind by QR, whitelist a sender, run the daemon). None of that is reproducible in the environment
|
|
712
|
+
this was built in, so the change would have shipped unverified into a path that handles the user's
|
|
713
|
+
real messages.
|
|
714
|
+
|
|
715
|
+
The failure mode is not a crash, which is what makes it worth writing down: if the router picks
|
|
716
|
+
the wrong tool, the model receives a result that does not answer the question and will phrase a
|
|
717
|
+
**confident** reply around it. That is worse than being slow.
|
|
718
|
+
|
|
719
|
+
**What it would take to land it safely:**
|
|
720
|
+
|
|
721
|
+
- an opt-in switch on the model of `--classifier` / `--no-rerank` / `--include-all`, **default
|
|
722
|
+
off**, so live behaviour is unchanged until someone watches it;
|
|
723
|
+
- a routing-confidence threshold that falls back to the normal loop, plus an assertion that the
|
|
724
|
+
fallback path is bit-identical to today's behaviour;
|
|
725
|
+
- a synthetic harness that drives `handleMessage` with a stubbed `callLLM` and a stubbed tool
|
|
726
|
+
executor, covering: routing correct, routing wrong (must degrade), routing uncertain (must
|
|
727
|
+
degrade), and **no tool dispatched without the audit line** - the last one is the security
|
|
728
|
+
property that must not regress;
|
|
729
|
+
- a way to observe real traffic before trusting it, e.g. run it in log-only mode ("would have
|
|
730
|
+
routed to X") alongside the normal loop for a week and compare.
|
|
731
|
+
|
|
732
|
+
**Until then** the loop stays as it is. One extra LLM round-trip is not worth an unverifiable
|
|
733
|
+
change to the path that mediates the user's messages.
|
|
734
|
+
|
|
735
|
+
**A reference implementation exists, and one of its tricks is directly transferable.** The
|
|
736
|
+
`browser-use` × TypeSafe demo repo (`jev_ultrafast`, MIT, ~1950 lines) ships this pattern in the
|
|
737
|
+
browser domain and publishes its measurements. Its key design point is that a **speculative
|
|
738
|
+
second question cannot see the first answer** - model calls inside one request are mutually
|
|
739
|
+
blind - so the target-selection question has to **state the operation it is assuming** in its own
|
|
740
|
+
instructions ("assuming the operation is TYPE_TEXT, which element is the target"). Each operation's
|
|
741
|
+
target is computed anyway and only the matching one is consumed, which is how "two decisions, one
|
|
742
|
+
round trip" works. That is the same move as writing the judging rule into the criteria text, one
|
|
743
|
+
level up: not just multiple questions, but questions with a **dependency** spelled out.
|
|
744
|
+
|
|
745
|
+
Its numbers are worth having as a shape rather than a baseline: 17 model requests for one flight
|
|
746
|
+
search, 90,558 input / 6,325 output tokens, 178 ms median latency, and the browser-protocol call
|
|
747
|
+
count dropping 1092 → 101 when one full snapshot replaced hundreds of round trips. It also states
|
|
748
|
+
the boundary this decision already assumed: **DONE is not evidence of success** - their version
|
|
749
|
+
keeps an independent post-check, and a failed development attempt (8.697 s) is recorded rather
|
|
750
|
+
than dropped.
|
|
751
|
+
|
|
752
|
+
**Its browser path is not portable to this repo's scenarios**, and the design doc says why:
|
|
753
|
+
shadow roots, iframes, canvas, file uploads, new tabs and nested scrolling are unsupported - which
|
|
754
|
+
is most of what the official-account console, n8n and conference-site work consists of. It also
|
|
755
|
+
drives its own stack (browser-harness + CDP + a reused Chrome profile), separate from whatever
|
|
756
|
+
browser automation this repo has. The transferable parts are the **question organisation** above
|
|
757
|
+
and the discipline of publishing measurement boundaries with the numbers, not the transport.
|
|
758
|
+
|
|
759
|
+
### Update 2026-09-22: shipped behind a default-off switch
|
|
760
|
+
|
|
761
|
+
The four prerequisites above are met, so the fast path now exists in
|
|
762
|
+
`src/services/assistantRouter.ts` and is wired into `handleMessage`. It is **off by
|
|
763
|
+
default**: `assistantFastRoute` accepts `off` (default, byte-identical to before), `log`
|
|
764
|
+
(decide, record "would have routed to X", dispatch nothing) and `on`.
|
|
765
|
+
|
|
766
|
+
What changed versus the deferred design:
|
|
767
|
+
|
|
768
|
+
- **Only tools with closed or empty arguments are routable.** The design assumed
|
|
769
|
+
`{tool, arguments}` could be resolved in one call; the arguments cannot, because a
|
|
770
|
+
`choice` can only pick from a closed set and "which contact", "which keyword" are free
|
|
771
|
+
text. Nine capabilities over six tools (`list_sessions`, `get_stats`, `get_daily_report`,
|
|
772
|
+
`get_sns` ×2, `get_weread` ×2, `get_todos` ×2) carry their arguments inside the option
|
|
773
|
+
text; everything else falls back. Passing the user's whole message as the argument would
|
|
774
|
+
have manufactured the exact failure this decision was written to avoid.
|
|
775
|
+
- The routing question is a `noul` ("must this be answered from local data?") plus a
|
|
776
|
+
`choice` over those nine capabilities **plus `none`**, whose criterion covers both "no
|
|
777
|
+
local data needed" and "local data needed, but not by any of these ways" - without the
|
|
778
|
+
second half the model is forced to pick among nine wrong options.
|
|
779
|
+
- **Every uncertain exit falls back**, and falling back *is* today's behaviour, not a
|
|
780
|
+
weaker new one: judge failure, non-numeric probability, sub-threshold confidence, an
|
|
781
|
+
unknown capability name. `asProbability` refuses booleans (`Number(true)` is `1`, so a
|
|
782
|
+
`noul: true` would have routed *certainly* - the same misread as the earlier client,
|
|
783
|
+
in the opposite direction).
|
|
784
|
+
- The dispatch and audit are now **one implementation** (`runToolCall`) shared by the loop
|
|
785
|
+
and the fast path, because "no tool dispatched without the audit line" is exactly the
|
|
786
|
+
kind of invariant that rots when written twice.
|
|
787
|
+
|
|
788
|
+
**Measured** (`scripts/assistant_route_probe.ts`, 17 representative questions, real
|
|
789
|
+
decision layer, 2026-09-22, free period): 15 matched the expectation I wrote in the probe,
|
|
790
|
+
**0 routed to a wrong capability**, 10 routed concretely, ~1.33 s per decision (two
|
|
791
|
+
outliers at 2.6 s and 4.2 s). The two mismatches fell back rather than routing: 我明天该干嘛
|
|
792
|
+
(needs_tool 0.40) and 最近有没有什么书可以读 (0.17) - and for the second one the *probe's*
|
|
793
|
+
expectation is the arguable one, since it asks for a recommendation rather than for what is
|
|
794
|
+
being read. The expectations are mine, not a human-labelled gold standard.
|
|
795
|
+
|
|
796
|
+
**Still not done, deliberately:** the log-only week on real traffic. That needs the
|
|
797
|
+
daemon running against a live channel, which is the user's to start; `log` mode exists for
|
|
798
|
+
exactly that. Free-text-argument tools remain unroutable until something can select a
|
|
799
|
+
value rather than a label - the same job `route_cards.py` does for conversation search.
|
|
800
|
+
|
|
801
|
+
### Update 2026-09-22 (later): a contradiction check, because the cause could not be found
|
|
802
|
+
|
|
803
|
+
Two live answers claimed a lookup had been made and the bodies masked, while the audit read `tools=0`.
|
|
804
|
+
Forty real calls across four prompt variants (current prompt, the conditional-only variant, a window
|
|
805
|
+
seeded with the model's own earlier "blocked" answers, and the complete pre-change prompt) **called the
|
|
806
|
+
tool 40/40 times**, so the first explanation written here - that the prompt's conditional was read as the
|
|
807
|
+
current state - is wrong, and the cause of those two turns is unknown.
|
|
808
|
+
|
|
809
|
+
What ships instead is a guard that does not depend on knowing the cause: when the router says the message
|
|
810
|
+
needs local data and the turn produced no tool call, the model is told it holds no tool result and the loop
|
|
811
|
+
runs once more. That adds one switch whose semantics differ from `assistantFastRoute`: the guard is armed
|
|
812
|
+
whenever the router ran, **including `log`**, because `log`'s promise is about routing rather than about
|
|
813
|
+
safety, and an observation period is when a fabricated lookup is most likely to be noticed. `off` remains
|
|
814
|
+
bit-identical because the router never runs and there is no signal to check.
|
|
815
|
+
|
|
816
|
+
## D-036: Search your own conversations with the decision model selecting query terms, not ranking sessions
|
|
817
|
+
|
|
818
|
+
**Status:** Active
|
|
819
|
+
|
|
820
|
+
`scripts/route_cards.py` answers "which conversation was I talking about X in". Two stages:
|
|
821
|
+
the decision model picks the real query words out of the candidate n-grams the question
|
|
822
|
+
produces, and **ranking is local**, by how many messages in each session literally contain
|
|
823
|
+
those words. Retrieval reads WeChat's own `message_fts.db`.
|
|
824
|
+
|
|
825
|
+
**Reason:** the obvious design - one card per conversation, ask the model which are
|
|
826
|
+
relevant - **was measured and does not work**. As a per-card `noul` every candidate scored
|
|
827
|
+
0.50-0.51, putting a one-message coupon group in the same band as the conference group with
|
|
828
|
+
213 hits and leaving out the session most worth opening; as a per-card `score` (0/1/2) all
|
|
829
|
+
20 candidates scored 1.74-1.76 whether they had 247 hits or 8. Local sorting by hit count
|
|
830
|
+
ranked the same data correctly. The card carries too little for the judgement being asked -
|
|
831
|
+
the model has never seen the conversation - so the shipped split gives ranking to the
|
|
832
|
+
objective local signal and leaves the model the job it demonstrably does well: choosing
|
|
833
|
+
words from a closed candidate list (it kept 会议 at 0.77 and rejected seven fragments at
|
|
834
|
+
0.11-0.40, consistently across runs). That is also the boundary of what it can do at all -
|
|
835
|
+
it cannot generate the search terms, only select them.
|
|
836
|
+
|
|
837
|
+
**Consequences and boundaries:**
|
|
838
|
+
- **New egress surface, deliberately small**: only the candidate words and the user's
|
|
839
|
+
question leave the machine. No message text, and - unlike the first design - no
|
|
840
|
+
conversation names, counts or timestamps either, since the model no longer sees cards.
|
|
841
|
+
`--keyword` skips the model entirely and keeps the whole path local.
|
|
842
|
+
- Ranking by literal match is the known limit: "聊过上线的事" will not find a conversation
|
|
843
|
+
that says 部署. That is what the embedding path (`search`) is for; the two are
|
|
844
|
+
complementary recall paths, and the tool says so instead of pretending to have found
|
|
845
|
+
nothing relevant.
|
|
846
|
+
- The article-favourite reading of `message_fts.db` is the enabling fact: `acontent` is
|
|
847
|
+
plaintext and `session_id` is the rowid of that database's own `name2id` table (595
|
|
848
|
+
sessions), so retrieval needs no index of our own. `MATCH` is unusable
|
|
849
|
+
(`no such tokenizer: MMFtsTokenizer`), but `LIKE` over 60k rows is instant.
|
|
850
|
+
- A `noul` answer is a **probability float**, not a boolean (measured: 0.98 true, 0.01
|
|
851
|
+
false). Reading it as a boolean makes every question look answered "no" while the output
|
|
852
|
+
blames the model - this happened, and the tool now separates "the answer shape changed"
|
|
853
|
+
from "the score is low" in what it prints.
|
|
854
|
+
|
|
855
|
+
## D-037: Topic exclusion lives in the display layer, not in front of the fetch
|
|
856
|
+
|
|
857
|
+
**Status:** Active
|
|
858
|
+
|
|
859
|
+
`dailyExcludeTopics` / `--exclude-topics` stop a topic from appearing in the daily report and
|
|
860
|
+
the reader page. Articles are still fetched, judged and archived; the topic is not shown. The
|
|
861
|
+
focus topic (AI) cannot be excluded - asking for it prints a warning and ignores that entry.
|
|
862
|
+
|
|
863
|
+
**Reason:** the feature was asked for as "decide before fetching whether I want this article".
|
|
864
|
+
That was measured and rejected: from a source name, a title and the platform digest - all that
|
|
865
|
+
exists before a fetch - the judgement agreed with the source-level configuration on **60%** of
|
|
866
|
+
217 articles, and where it disagreed it was mostly wrong in the direction that drops articles
|
|
867
|
+
worth keeping. There is no gold standard either way, so "the model said 新闻" cannot justify a
|
|
868
|
+
dropped fetch. The asymmetry decides it: an unwanted article costs a little space and attention,
|
|
869
|
+
while a wrongly skipped one is **gone** - it was never archived, and re-fetching a 公众号 page
|
|
870
|
+
weeks later is not the same article. The display layer also buys a cheaper correction path:
|
|
871
|
+
changing your mind is a regeneration, not a refetch.
|
|
872
|
+
|
|
873
|
+
**Consequences and boundaries:**
|
|
874
|
+
- Exclusion is **orthogonal**: it is checked before `--include-all` and before the inclusion
|
|
875
|
+
score, so no flag combination brings an excluded topic back. All three lists in **我拿不准的**
|
|
876
|
+
(near-threshold admitted, near-threshold not admitted, low topic confidence) are filtered too.
|
|
877
|
+
This is not cosmetic: the "not admitted" list is populated by exactly the articles being
|
|
878
|
+
excluded, so passing the exclusion set to `admits()` there does the opposite and makes them
|
|
879
|
+
*more* likely to be listed. One predicate (`is_excluded_topic`) serves both directions.
|
|
880
|
+
- The focus topic is protected because excluding it does not produce an empty report, it
|
|
881
|
+
produces a misleading one: with nothing left, the report exits with "未找到文章,请先运行
|
|
882
|
+
biz_daily.py" and blames the wrong thing. Unknown names and refused names both print a WARN;
|
|
883
|
+
silently ignoring either would make an ineffective filter look like a working one.
|
|
884
|
+
- Both views take the same set, and `generate_html.collect_articles` skips an excluded topic
|
|
885
|
+
directory outright rather than filtering after the scan, so the page and the report cannot
|
|
886
|
+
disagree about what was excluded.
|
|
887
|
+
- The key is registered in all four `configService` literals plus `bin`'s `configurableKeys`;
|
|
888
|
+
`test/config-keys.test.ts` asserts every key in that list is declared, defaulted, reset and
|
|
889
|
+
read back.
|
|
890
|
+
|
|
891
|
+
## D-038: The source-level prior is recorded from real judgements, and acts on nothing
|
|
892
|
+
|
|
893
|
+
**Status:** Active
|
|
894
|
+
|
|
895
|
+
Every daily run appends `(source, topic)` counts for the articles it archived to
|
|
896
|
+
`~/.weflow-cli/source_topics.json`. The run reports which sources are now stable and which of
|
|
897
|
+
them publish a topic you exclude. It does **not** skip anything.
|
|
898
|
+
|
|
899
|
+
**Reason:** the per-article judgement D-037 rejected is unreliable because its input is thin.
|
|
900
|
+
There is a second signal, but only after the fact: every archived article has a topic decided
|
|
901
|
+
from its **body**, so a source's history answers what a title cannot. That table has to be
|
|
902
|
+
measured rather than authored - a hand-written source-to-topic map would be a guess wearing a
|
|
903
|
+
table's clothes, and D-037 exists because that guess was already measured at 60%. Recording is
|
|
904
|
+
therefore a prerequisite for ever making the skip decision, not the decision itself.
|
|
905
|
+
|
|
906
|
+
**Consequences and boundaries:**
|
|
907
|
+
- Counts are **append-only**: cumulative counts are the only evidence for "this account is
|
|
908
|
+
stable", and pruning them destroys the basis for a later decision. A failed write reports the
|
|
909
|
+
OS error and leaves the run alone - the table is auxiliary data and must not fail a day's
|
|
910
|
+
daily - but it does report, since a silent failure looks exactly like a table that is growing.
|
|
911
|
+
- Only articles that landed on disk are recorded (matched against `written_urls`), so the table
|
|
912
|
+
and `output/` describe the same corpus and can be reconciled later. This was a real trap:
|
|
913
|
+
`topic_groups` is rebuilt for the briefing as topic-to-strings before the end of the run, so
|
|
914
|
+
recording from it produces an empty table.
|
|
915
|
+
- The file holds 公众号 names, so it lives outside the repository; a test asserts the path is
|
|
916
|
+
under the home directory and not under the repo. It follows `CONFIG_PATH` at call time, so
|
|
917
|
+
tests - and any future multi-profile setup - can point it at a temporary directory.
|
|
918
|
+
- `stable_source_topic` returns `None` for "not enough history yet" as distinct from "no topic".
|
|
919
|
+
The distinction is the point: `None` read as a default topic would skip a source precisely
|
|
920
|
+
because it is unknown, which is the worst failure this feature has.
|
|
921
|
+
- Thresholds (8 samples, 80% share) are **provisional and uncalibrated**, chosen so the first
|
|
922
|
+
weeks of data cannot produce a confident wrong answer. Whether a stable source is ever skipped
|
|
923
|
+
before the fetch remains the user's decision; the numbers are printed each run so it can be
|
|
924
|
+
made on data rather than on a promise.
|
|
925
|
+
|
|
926
|
+
## D-039: The daemon confirms at the parent, and reports a child that dies
|
|
927
|
+
|
|
928
|
+
**Status:** Active
|
|
929
|
+
|
|
930
|
+
`assistant start` spawns `assistant run` detached. The spawn now passes `--yes`, and
|
|
931
|
+
`startDaemon` observes the child for a short window before reporting success. A dead env marker
|
|
932
|
+
(`WEFLOW_ASSISTANT_DAEMON`) was deleted.
|
|
933
|
+
|
|
934
|
+
**Reason:** the child was spawned without `--yes`, while `assistant run` confirms through
|
|
935
|
+
`inquirer` - and the daemon gives it `stdin: 'ignore'`, so nobody could ever answer. The child
|
|
936
|
+
died on the prompt (or later, on the missing channel login) while the parent printed
|
|
937
|
+
「✓ 守护进程已启动 (pid …)」 and wrote the pid file. On the machine this was developed against,
|
|
938
|
+
`~/.weflow-cli/` had **no `assistant.log` and no `assistant.pid` at all**: the documented
|
|
939
|
+
`assistant start` flow had never once completed. The env marker that appeared to handle this
|
|
940
|
+
was read by nothing, and was constructed as an env *key* containing `=` (`'…=1'`), so it could
|
|
941
|
+
not have worked even if something read it - a dead mechanism that made the path look guarded.
|
|
942
|
+
|
|
943
|
+
**Consequences and boundaries:**
|
|
944
|
+
- Confirmation stays **at the parent**: a human typed `assistant start` (or a machine passed
|
|
945
|
+
`--json --yes`). The child inherits that decision as an explicit argument, not as an ambient
|
|
946
|
+
env var - an env-var-based bypass would be inherited by every grandchild process, which is
|
|
947
|
+
exactly the shape the repo's permission rules avoid.
|
|
948
|
+
- The failure is now **observed, not assumed**: the child is watched for `SETTLE_MS` (default
|
|
949
|
+
700 ms) and a child that has already exited is reported with its exit code and the tail of the
|
|
950
|
+
daemon log, and **no pid file is written** - writing one would be a claim that it is running.
|
|
951
|
+
- A spawn `'error'` event is now handled; without a listener Node turns it into an uncaught
|
|
952
|
+
exception in the caller.
|
|
953
|
+
- Verified live: `assistant start` on this machine now says
|
|
954
|
+
`子进程启动后立即退出 (code 1);日志尾部: Error: 未登录消息通道, 先运行 weflow-cli login-wechat`.
|
|
955
|
+
The fix did not break the daemon - it made a broken state visible.
|
|
956
|
+
- The daemon runs `dist/bin/weflow-cli.js` **when it exists**, so source changes need
|
|
957
|
+
`npm run build` before the daemon reflects them. That precedence is a live operational trap,
|
|
958
|
+
now written down in OPERATIONS.md.
|
|
959
|
+
- Untested and left untested: the daily quota (`100 条/天`) and the whole `start()` message
|
|
960
|
+
loop, because both require a logged-in WeChat channel. The per-message logic is covered by a
|
|
961
|
+
synthetic harness instead (stubbed `callLLM`, local-only tools).
|
|
962
|
+
|
|
963
|
+
## D-040: The fetch guard is a denylist of known forms, and it says so
|
|
964
|
+
|
|
965
|
+
**Status:** Active
|
|
966
|
+
|
|
967
|
+
`assistantTools.isSafeUrl` decides whether the assistant may fetch a URL found in a favourite.
|
|
968
|
+
It is now covered by tests, and two real holes were found and closed: IPv6 forms were not
|
|
969
|
+
handled at all (`[::ffff:127.0.0.1]` - an IPv4-mapped loopback - as well as `[fd00::1]` and
|
|
970
|
+
`[fe80::1]` were **allowed**), while the private-range regexes were matched against any
|
|
971
|
+
hostname, so a legitimate public domain such as `10.example.com` was refused.
|
|
972
|
+
|
|
973
|
+
**Reason:** the guard runs immediately before a `fetch` made by a process that also holds the
|
|
974
|
+
local database handle, and its failure mode is silent in both directions - a bypass reaches
|
|
975
|
+
loopback or a cloud metadata address, and an over-block merely looks like a broken link
|
|
976
|
+
(`(链接不安全, 拒绝抓取: …)`). Neither raises. Testing it was the only way to find out which
|
|
977
|
+
direction it was wrong in; the measurement also settled an assumption: Node's URL parser
|
|
978
|
+
normalises integer IPv4 forms (`2130706433`, `0x7f000001`) to `127.0.0.1` **before** this
|
|
979
|
+
function sees them, so the dotted-quad check already covered them - that is now a test rather
|
|
980
|
+
than a belief.
|
|
981
|
+
|
|
982
|
+
**Consequences and boundaries:**
|
|
983
|
+
- IPv6 is handled by prefix: `::`, `::1`, `::ffff:` (IPv4-mapped), `fc00::/7` (unique-local),
|
|
984
|
+
`fe80::/10` (link-local) are refused. `[0:0:0:0:0:0:0:1]` arrives already normalised to
|
|
985
|
+
`[::1]`.
|
|
986
|
+
- The dotted-quad private-range checks now apply **only when the hostname really is four
|
|
987
|
+
dotted octets**, which is what stops `10.example.com` from being caught by `^10\.`.
|
|
988
|
+
- This is a denylist of known forms, **not a proof that an address is publicly routable**.
|
|
989
|
+
NAT64 (`64:ff9b::/96`) and similar mappings are not covered. The comment in the code says so,
|
|
990
|
+
so nobody reads the guard as a stronger guarantee than it is.
|
|
991
|
+
- The pure helpers (`isSafeUrl`, `stripTags`, `extractText`, `extractFromChallengePage`) are
|
|
992
|
+
exported for testing. They take strings and return strings - no side effects, no configuration.
|
|
993
|
+
|
|
994
|
+
## D-041: The memory file carries a version, and an unknown version is refused rather than migrated
|
|
995
|
+
|
|
996
|
+
**Status:** Active
|
|
997
|
+
|
|
998
|
+
`~/.weflow-cli/assistant_memory.json` now starts with `version: 1` and puts every conversation under a
|
|
999
|
+
`users` key. A file without a `version` is the previous shape (v0, conversations at the top level) and is
|
|
1000
|
+
**migrated**; a file whose version is anything else is **refused**, renamed to
|
|
1001
|
+
`assistant_memory.json.unreadable-<timestamp>`, and the assistant starts with an empty memory and says so
|
|
1002
|
+
in the log and the audit.
|
|
1003
|
+
|
|
1004
|
+
**Reason:** the format had no version at all, and the repo freezes schemas for its other artifacts
|
|
1005
|
+
(`docs/SYNC_CONTRACT.md`, the `reconstructed` provenance block in `.articles.json`) — memory was the
|
|
1006
|
+
exception, and the cost of that lands on the first migration. The write criterion came from the same
|
|
1007
|
+
audit as the rest of this change: `deepseek-harness` pins its session format at `0` with the note that
|
|
1008
|
+
**no compatibility is implied and no migration is provided**, and rejects anything else at load. That
|
|
1009
|
+
combination (field + written criterion + refuse-don't-guess) is cheap and removes the "we will figure it
|
|
1010
|
+
out later" debt.
|
|
1011
|
+
|
|
1012
|
+
**Consequences and boundaries:**
|
|
1013
|
+
- **What counts as a structural change** (this criterion is part of the format, not an afterthought):
|
|
1014
|
+
renaming or removing a field, changing a field's meaning or units, or changing the key space (the
|
|
1015
|
+
`users` layer). **Adding an optional field does not** — readers ignore fields they do not know, and a
|
|
1016
|
+
test pins that a same-version file with unknown fields still loads.
|
|
1017
|
+
- v0 is migrated because **we wrote it** and know its exact shape. Unknown versions are refused because we
|
|
1018
|
+
do not: reading another producer's fields by guess is how silent corruption gets in.
|
|
1019
|
+
- Refusing must not destroy data: the rename to `unreadable-<timestamp>` is mandatory and the startup log
|
|
1020
|
+
names the file. A file that cannot be parsed at all takes the same path — it may be the user's only copy.
|
|
1021
|
+
- The same audit produced two other changes here rather than the file format: the compression **retains by
|
|
1022
|
+
ratio, not by a fixed turn count** (a fixed count is window-independent and goes wrong the moment the
|
|
1023
|
+
model changes), and the summariser prompt is a **fixed eight-section skeleton** with an explicit merge
|
|
1024
|
+
law ("keep what is still true, drop what is stale, never copy the previous summary verbatim"). The two
|
|
1025
|
+
gates differ in what they retain, deliberately: the turn gate keeps about half, the budget gate keeps
|
|
1026
|
+
what fits 16% of the budget and lets the character bound win over the turn floor, because six 6,000
|
|
1027
|
+
character turns are 36,000 characters and no turn floor justifies exceeding the input budget.
|
|
1028
|
+
|
|
1029
|
+
## D-042: The assistant looks at one image per request, and the picture is what must not leak
|
|
1030
|
+
|
|
1031
|
+
**Status:** Active
|
|
1032
|
+
|
|
1033
|
+
The assistant gained a `look_at_image` tool. `get_messages` renders an image message as
|
|
1034
|
+
`[图片 #<local_id>]`, the model calls `look_at_image {contact, image}`, the reader decrypts that one
|
|
1035
|
+
image **locally** and the service attaches it to the next request as an OpenAI `image_url` content part.
|
|
1036
|
+
|
|
1037
|
+
Images ride a side channel (`ToolContext.pendingImages`) and are turned into multimodal content in
|
|
1038
|
+
**exactly one place** (`toApiMessages`). Memory, redaction, quota and the audit keep operating on text
|
|
1039
|
+
`content`; nothing else in the codebase has to know that content can be an array.
|
|
1040
|
+
|
|
1041
|
+
**Reason:** 15.5% of the messages in one measured 30-day archive are images (242 of 1564), and the model
|
|
1042
|
+
saw `[图片]` and nothing else - the largest remaining capability gap, and one no amount of prompt work
|
|
1043
|
+
closes. The alternative that was measured first got rejected on the same evidence: voice is 0.8% (12
|
|
1044
|
+
messages a month), so a transcriber on the read path would never pay for itself.
|
|
1045
|
+
|
|
1046
|
+
**Consequences and boundaries:**
|
|
1047
|
+
- **An image is egress, and it is treated as egress.** `strict` mode ("third-party chat text does not
|
|
1048
|
+
leave the machine") holds images back at **two** layers: the tool refuses to fetch *and* refuses to
|
|
1049
|
+
hand anything back, and `toApiMessages` drops whatever got through anyway. The tool layer is the cheap
|
|
1050
|
+
one; the request layer is the one that has to be right, because "the tool was willing" and "the gate
|
|
1051
|
+
let it out" are different statements. A drop is **stated in the body it attaches to**, never silent: a
|
|
1052
|
+
request that quietly lost an image looks to the model exactly like one that never had it.
|
|
1053
|
+
- `get_messages` shows the `#N` handle **only when the mode allows images out**. Advertising a handle
|
|
1054
|
+
that the tool will certainly refuse is worse than not mentioning it.
|
|
1055
|
+
- Looking is **on demand, one image per call, at most two per turn**. That is not a technical limit: both
|
|
1056
|
+
cost and exposure scale with pixels, and "the model chose to look at this picture" is a decision that
|
|
1057
|
+
belongs in the audit. Local engines (`ollama`, `lmstudio`) are exempt from the hold, and no
|
|
1058
|
+
`IMAGE_SENT` line is written for them - that line records **egress**, and writing it for a call that
|
|
1059
|
+
never left the machine would make the log lie about where data went.
|
|
1060
|
+
- Audit gained `IMAGE_SENT <bytes> n=<count> ids=<local_ids>` and `IMAGE_HELD <count> strict`. Byte
|
|
1061
|
+
counts and ids only, **never content**, like every other audit line.
|
|
1062
|
+
- Images never enter memory. Turns are stored as text, so a later turn sees the sentence the model wrote
|
|
1063
|
+
about the picture, not the picture. That is both the honest behaviour and the cheap one.
|
|
1064
|
+
- **Where the time goes, and the one cache.** Building the media index is the entire cost of a read, and
|
|
1065
|
+
it scales with the conversation: measured 0.9 s for a direct chat and **18.9 s for a group with 30,315
|
|
1066
|
+
messages** (that group's local media is thumbnails only, 290×435). The resolved, downscaled bytes are
|
|
1067
|
+
therefore cached at `output/.cache/read-image/<md5(talker)>/<local_id>.json`, which makes a second look
|
|
1068
|
+
at the same picture cheap. **Only hits are cached** - a miss may be "WeChat is not running" (see below),
|
|
1069
|
+
which is a state that changes, and caching it would make it permanent. The timeout is 90 s (≈4× the
|
|
1070
|
+
worst measured) and a timeout is reported as a failure rather than silently retried: the assistant
|
|
1071
|
+
handles messages serially, so one stuck read blocks every later message.
|
|
1072
|
+
- **"This picture is not here" and "I cannot see it right now" are different answers.** The V2 thumbnail
|
|
1073
|
+
key is derived from a file WeChat maintains while running; with WeChat closed the derivation returns
|
|
1074
|
+
nothing and most `.dat` thumbnails drop out of the index **silently**. A miss therefore carries a hint
|
|
1075
|
+
saying which of the two it is, instead of reporting a state that may be false.
|
|
1076
|
+
- What this does **not** do: no describe-once-and-cache, so looking at the same picture twice still costs
|
|
1077
|
+
two vision calls (only the local bytes are cached); no vision on stickers (type 47) or video (43), only
|
|
1078
|
+
type 3 images; no OCR fallback when the model cannot read something.
|
|
1079
|
+
- Known limit: an image whose local copy is genuinely gone returns a stated reason rather than a picture.
|
|
1080
|
+
The tool says so; it does not pretend to have looked. Note the honest scope of that claim: across 380
|
|
1081
|
+
sampled messages every image resolved, so the miss path was exercised by removing index entries, not by
|
|
1082
|
+
observing a real eviction.
|
|
1083
|
+
|
|
1084
|
+
## D-043: The assistant records a per-turn trace, and the audit stays what it was
|
|
1085
|
+
|
|
1086
|
+
**Status:** Active
|
|
1087
|
+
|
|
1088
|
+
Every assistant turn now writes one record to `~/.weflow-cli/assistant_trace.jsonl`: the fast-route's
|
|
1089
|
+
decision, each tool call with a **redacted argument summary**, how many model round trips it took, why
|
|
1090
|
+
it stopped, and how much reasoning the model returned. Two ways to read it: `weflow-cli assistant trace
|
|
1091
|
+
[-n N]` on the machine, and the in-chat command `轨迹` for the last turn (no `userId` in that one).
|
|
1092
|
+
|
|
1093
|
+
**Reason:** the assistant's only output used to be the reply itself. Everything in between lived in the
|
|
1094
|
+
audit as separate event lines - `TURN_DONE tools=5` says five tools were called and nothing about which
|
|
1095
|
+
five, in what order, or with what arguments. When the answer looked wrong there was no way to see which
|
|
1096
|
+
step went wrong, and the one time this mattered (a turn that called `search_favorites` twice and
|
|
1097
|
+
`read_favorite` twice) the arguments were not recorded, so the question could not be answered from the
|
|
1098
|
+
log at all.
|
|
1099
|
+
|
|
1100
|
+
**Consequences and boundaries:**
|
|
1101
|
+
- **The audit is unchanged and must stay narrow.** It records events and byte counts and **never
|
|
1102
|
+
content**, because it is the egress record. The trace is a *local* debugging artifact and does contain
|
|
1103
|
+
redacted argument summaries - that is the whole point of it, and it is why it is a separate file with
|
|
1104
|
+
a separate reader rather than more fields on the audit line.
|
|
1105
|
+
- Argument summaries go through `privacyGate.redact` and truncate each value at 40 characters. The
|
|
1106
|
+
in-chat rendering (`轨迹`) is the only path where they leave the machine, which is no more than the
|
|
1107
|
+
reply itself already does; `userId` is deliberately omitted there.
|
|
1108
|
+
- **Reasoning is whatever the model returns**, surfaced from `reasoning_content` and clipped at 800
|
|
1109
|
+
characters. The default model (`deepseek-chat`) does not return it, so the field is usually empty and
|
|
1110
|
+
the trace says "无" - it does not pretend. Switching to a reasoning model fills it in; nothing else
|
|
1111
|
+
changes. The reasoning text is **not** fed back into the conversation: it is the model's monologue,
|
|
1112
|
+
not content for the user.
|
|
1113
|
+
- **"Did this step produce anything" is a convention, not a field.** The rule is "the result starts
|
|
1114
|
+
with `(`", which is how this codebase already writes every "could not do it" reply (`fail()` and the
|
|
1115
|
+
per-branch misses); the single exception, a success that also starts with `(`, is a whitelist entry
|
|
1116
|
+
guarded by a test that scans the tool source. Measuring the honesty of this: a wrong answer here
|
|
1117
|
+
costs one extra or missing "(无内容)" marker in a debugging view. The alternative - threading an
|
|
1118
|
+
outcome through roughly forty return sites - was priced and not done; if a future feature needs a
|
|
1119
|
+
machine-readable outcome (say, retrying differently on failure), that is the change to make, and this
|
|
1120
|
+
convention should then be deleted rather than extended.
|
|
1121
|
+
- The trace is capped at 512 KB, trimmed to the most recent 200 turns. It is not a metrics store and
|
|
1122
|
+
there is no aggregation over it: `assistant trace` prints turns, it does not compute rates. The
|
|
1123
|
+
behaviour eval (D-042's sibling work) is where aggregate numbers come from, because it asserts
|
|
1124
|
+
conditions instead of dumping history.
|
|
1125
|
+
- Writing a trace can never break a turn (failures are swallowed), which is the same posture
|
|
1126
|
+
`AssistantMemory.save` takes - with the same obligation: because it is swallowed, `recordTurn`
|
|
1127
|
+
creates `~/.weflow-cli` if it is missing, or a fresh machine would silently record nothing. That was
|
|
1128
|
+
caught by the feature's own tests.
|
|
1129
|
+
|
|
1130
|
+
## D-045: The local panel is a token-gated loopback endpoint on the existing daemon, and the panel is only a client
|
|
1131
|
+
|
|
1132
|
+
**Status:** Active
|
|
1133
|
+
|
|
1134
|
+
The assistant gained a second entrance: a small always-on-top panel window on the same machine, so
|
|
1135
|
+
talking to it does not require logging into the WeChat channel. It is implemented as a
|
|
1136
|
+
**loopback-only HTTP endpoint inside the assistant daemon** (`src/panel/server.ts`), plus a client
|
|
1137
|
+
(`weflow-cli panel`, the page under `resources/panel/`, and an Electron shell). The panel holds no
|
|
1138
|
+
assistant of its own: every turn goes through the daemon's existing `runTurn` — the same allowlist
|
|
1139
|
+
path for WeChat, the same daily quota counter, the same serial queue, the same memory file.
|
|
1140
|
+
|
|
1141
|
+
**Reason:** the quota counter, the serial queue and the instance fields it protects (`turnCalls`,
|
|
1142
|
+
`lastReasoning`) are **in-process state**. A panel that built its own `AssistantService` would get a
|
|
1143
|
+
second quota (each entrance 100/day, total unbounded), a second queue protecting nothing, and two
|
|
1144
|
+
processes writing the same user's memory window (the file's read-merge-write protects *other* users,
|
|
1145
|
+
but for the same `userId` it is still last-writer-wins). The cheapest correct shape was therefore one
|
|
1146
|
+
host process with two entrances.
|
|
1147
|
+
|
|
1148
|
+
**Consequences and boundaries:**
|
|
1149
|
+
- **Loopback only, not configurable** (continuing D-004), plus two gates the reader does not have.
|
|
1150
|
+
`scripts/fav_server.py` has **no token at all** and its origin check **allows a request with no
|
|
1151
|
+
Origin header**; copying that here would hand an endpoint that reads chat data to any local program.
|
|
1152
|
+
So: **every request carries a token** (`/api/status` included), Origin is a second gate (present but
|
|
1153
|
+
not allowlisted → 403 even with a correct token; **absent → allowed**, because `file://` renderers
|
|
1154
|
+
and `curl` look like that), and POSTs must be `application/json`.
|
|
1155
|
+
- **The token is per-run and lives in a file next to the pid file** (`assistant_endpoint.json`, atomic
|
|
1156
|
+
write, read-merge not needed). It is **not** claimed to be protected by file permissions: on Windows
|
|
1157
|
+
`fs.chmod` / `mode: 0600` do essentially nothing, and what actually helps is the default ACL on
|
|
1158
|
+
`%USERPROFILE%\.weflow-cli\`. The token's real job is to stop **other local programs and any web
|
|
1159
|
+
page**, not a same-user process willing to read that directory — such a process can already read the
|
|
1160
|
+
database. **Cleanup is not relied on**: `stopDaemon` sends SIGTERM and Node does not run handlers for
|
|
1161
|
+
it, so the file is expected to be left behind; readers probe `kill(pid, 0)` and then verify the
|
|
1162
|
+
`service` + `startedAt` fields before trusting a port.
|
|
1163
|
+
- **The panel's identity is a memory bucket, and the bucket decides whether it is "one brain".**
|
|
1164
|
+
Memory facts are stored per `userId`, so sharing means using the same string as the WeChat DM. The
|
|
1165
|
+
bucket resolves to the **single** entry of `assistantWhitelist` when there is exactly one; with zero
|
|
1166
|
+
or several it does **not guess** — it falls back to a `panel` bucket and the interface says so, and
|
|
1167
|
+
`assistantPanelUser` pins the choice. Note what is inference and what is observed: the chain
|
|
1168
|
+
(DM `conversationId` == `senderId` == the allowlist value) is sound in the code, but it has **never
|
|
1169
|
+
been observed on a real inbound WeChat message** — the channel on this machine has never completed a
|
|
1170
|
+
login. The panel displays its resolved bucket so the first real message can be compared by eye.
|
|
1171
|
+
- **Credentials never enter a command line.** Windows exposes any process's argv to any local user
|
|
1172
|
+
(`wmic process get commandline`), so the Electron path passes **no** arguments and reads the
|
|
1173
|
+
endpoint file itself, then installs the token as an `HttpOnly` cookie before loading the page. The
|
|
1174
|
+
browser fallback cannot do that, so it uses a **one-time, 60-second, single-use code** from
|
|
1175
|
+
`/api/pair` — a code in argv or in browser history is spent, a token there would not be.
|
|
1176
|
+
- **`AssistantService.start()` no longer refuses to start without a channel.** It used to throw
|
|
1177
|
+
`未登录消息通道` before doing anything, so "not logged into WeChat" meant "no assistant at all"
|
|
1178
|
+
(recorded in PROJECT_STATE as a field observation). Now the channel is optional and the local
|
|
1179
|
+
entrance still comes up. The cost is that `assistant start` can now report success with an empty
|
|
1180
|
+
channel, so `assistant status` gained `channelActive` / `mode` / `panelPort` / `memoryBucket` —
|
|
1181
|
+
`messageChannelLoggedIn` only ever meant "is a token configured".
|
|
1182
|
+
- **The panel authenticates by token, not by the WeChat allowlist.** Reusing `evaluateAssistantAccess`
|
|
1183
|
+
would deny the panel when the allowlist is empty (i.e. by default) and, worse, print the
|
|
1184
|
+
"run `config set assistantWhitelist <id>`" bootstrap hint telling the user to add **their own panel
|
|
1185
|
+
identity** to the WeChat allowlist. The allowlist answers "who may talk to me in WeChat"; it is not
|
|
1186
|
+
the right gate for the person sitting at the machine.
|
|
1187
|
+
- **Verified on Windows 11 with Electron 42** (the binary had to be downloaded first - it was
|
|
1188
|
+
declared but not installed, so the browser fallback was the only path for a while): the ball window
|
|
1189
|
+
measures 76x76 with no caption (frameless) and `WS_EX_TOPMOST` set, the renderer loads the page and
|
|
1190
|
+
authenticates **through the cookie** (visible in the daemon log as `[panel] 界面已加载(cookie)`),
|
|
1191
|
+
and the ball is visible on screen - confirmed by eye, because **GDI screen capture does not capture a
|
|
1192
|
+
transparent layered window**, so a screenshot showing nothing at that spot is a false negative.
|
|
1193
|
+
The interactive paths were then driven **without** a click: over the DevTools protocol the ball was
|
|
1194
|
+
really clicked (the window measured 421x560, always-on-top dropped) and collapsed again (77x76,
|
|
1195
|
+
always-on-top restored); the global hotkey was verified differentially - a second Electron app
|
|
1196
|
+
registering the same chord gets `false` while the panel runs and `true` once it stops, so the panel
|
|
1197
|
+
genuinely holds it. Driving it that way found **two real bugs that a screenshot could never have
|
|
1198
|
+
shown**: `setMode` locked the window non-resizable *before* resizing it, and Windows ignores
|
|
1199
|
+
`setSize` on a non-resizable window, so collapsing left the window at chat size (a giant ball); and
|
|
1200
|
+
the `ready-to-show` listener was attached *after* `await loadURL`, so when that event fired during
|
|
1201
|
+
the load it was never replayed and the window stayed hidden - geometry and style all measured
|
|
1202
|
+
correct, only `IsWindowVisible` was false. A third bug came out of exercising what the tray items *do* rather than clicking them: the
|
|
1203
|
+
"quit and stop the assistant" action spawned the CLI as `spawn(electron, [cli.cjs, …])`, which
|
|
1204
|
+
fails with `error: unknown command '…\cli.cjs'` because commander in Electron's Node mode does not
|
|
1205
|
+
skip `process.argv[1]`. The repository already knew this (`bin/weflow-cli-electron.cjs` documents
|
|
1206
|
+
it); the working form is `-e "import('file:///…')" -- <args>`, with the path passed through
|
|
1207
|
+
`pathToFileURL` because this checkout's directory name is non-ASCII. Verified by running that exact
|
|
1208
|
+
shape: the daemon stops and both the pid file and the endpoint file are cleaned up. The one thing
|
|
1209
|
+
still unverified is a **click on the tray menu itself** - its contents are pinned by static
|
|
1210
|
+
assertions, and each item's effect has been exercised directly.
|
|
1211
|
+
- **Placement and drag were then closed too, and they were not merely untested - they were wrong.**
|
|
1212
|
+
The window was created without `x`/`y`, so it landed wherever Windows put it (measured 815,418,
|
|
1213
|
+
mid-left) - not "a ball in the corner". It now defaults to the primary work area's bottom-right
|
|
1214
|
+
corner, **remembers where it was dragged** (`~/.weflow-cli/panel_position.json`, atomic write), and
|
|
1215
|
+
falls back to the corner when the remembered spot is unreachable (a monitor was unplugged, or the
|
|
1216
|
+
ball was dragged off-screen). Expanding to the chat window and collapsing back both re-fit into the
|
|
1217
|
+
work area, because a 420x560 window opened at a bottom-right corner would otherwise hang off the
|
|
1218
|
+
screen. Two details worth keeping: the listener is `move`, not `moved` - `moved` fires on
|
|
1219
|
+
`WM_EXITSIZEMOVE`, which a programmatic move never produces (measured: the window moved and the
|
|
1220
|
+
position file was not written) - and only the **ball's** position is saved, since the ball is the
|
|
1221
|
+
anchor and the chat window's geometry is transient.
|
|
1222
|
+
The arithmetic lives in `resources/panel/ball-position.cjs` so it can be tested in CI with
|
|
1223
|
+
**synthetic monitor layouts** - negative-x second displays, a removed display, a rect larger than
|
|
1224
|
+
the work area - because **this machine has one monitor and cannot produce those**. What that does
|
|
1225
|
+
not cover: a real second display. The code path is exercised with synthetic coordinates; plugging
|
|
1226
|
+
in a second monitor is still an unperformed experiment.
|
|
1227
|
+
|
|
1228
|
+
## D-044: "You have no todos" and "todos were never extracted" are different answers
|
|
1229
|
+
|
|
1230
|
+
**Status:** Active
|
|
1231
|
+
|
|
1232
|
+
`extract_todos.py list` now reports whether the todo file exists, and every reader keeps the two cases
|
|
1233
|
+
apart. The bare-array shape of `list --json` is unchanged; the distinction rides on a new `--meta` flag
|
|
1234
|
+
(`{items, extracted, count}`). The assistant tool calls `list --json --meta` and, when `extracted` is
|
|
1235
|
+
false, answers "these have never been extracted - run `weflow-cli todos extract --days 7 --yes` first;
|
|
1236
|
+
until then this list is empty, which does not mean you have nothing to do" instead of "no pending
|
|
1237
|
+
todos". The terminal output of `list` and `remind` says the same thing.
|
|
1238
|
+
|
|
1239
|
+
**Reason:** Extraction is a deliberate, confirmed action (`todos extract` carries its own `--yes` gate),
|
|
1240
|
+
so on a machine where it has never been run the list is empty for a reason that has nothing to do with
|
|
1241
|
+
the user's workload. Reporting that emptiness as "nothing to do" asserts a state that was never
|
|
1242
|
+
established - the same failure the `(未查到会话, 数据库可能未连接)` wording avoids. It was not
|
|
1243
|
+
hypothetical: on this machine `~/.weflow-cli/todos.json` does not exist at all, and the assistant's
|
|
1244
|
+
`get_todos` was answering `(没有待办任务)`.
|
|
1245
|
+
|
|
1246
|
+
**Consequences and boundaries:**
|
|
1247
|
+
- The `--json` array shape stays because it is a published capability (`bin/weflow-cli.ts` maps
|
|
1248
|
+
`todos: { cli: 'todos list --json' }`), so a marker inside that array would have been a compatibility
|
|
1249
|
+
break dressed up as a fix. The tool also tolerates the old shape: if it receives an array rather than
|
|
1250
|
+
the meta object, it falls back to the previous wording rather than reading `undefined.items` as
|
|
1251
|
+
"never extracted".
|
|
1252
|
+
- The MCP side (`mcp_bridge.py`) has always separated these two cases - it already said "no todo file
|
|
1253
|
+
yet, run extract_todos.py first". The assistant tool was the one implementation that dropped the
|
|
1254
|
+
information, which is what made this look like a tool defect rather than a missing feature.
|
|
1255
|
+
- Not fixed: nothing here makes extraction happen. The pipeline that would run it on a schedule is the
|
|
1256
|
+
same open question as the daily report's (no scheduled task is registered on this machine).
|
|
1257
|
+
|
|
1258
|
+
## D-046: Atomic writes retry a busy target, and fall back to writing in place
|
|
1259
|
+
|
|
1260
|
+
**Status:** Active
|
|
1261
|
+
|
|
1262
|
+
`writeFileAtomic` (`src/utils/atomicWrite.ts`) writes `<target>.tmp` and renames it over the target, as
|
|
1263
|
+
before - but when the rename fails with `EPERM` / `EACCES` / `EBUSY` it retries five times with a short
|
|
1264
|
+
synchronous backoff, and if the target is still busy it **writes the target directly** instead of giving
|
|
1265
|
+
up. Both `AssistantMemory.save()`, `configService.save()` and the panel's endpoint file go through it.
|
|
1266
|
+
|
|
1267
|
+
**Reason:** on Windows, `renameSync` over a file that **another handle has open** fails with `EPERM` -
|
|
1268
|
+
measured: a second handle opening the target read-only is enough, and the rename succeeds the moment that
|
|
1269
|
+
handle closes. Antivirus, search indexers and other readers produce that state briefly and routinely. The
|
|
1270
|
+
callers all swallow the failure (they record a reason and carry on), so the result was a **silent lost
|
|
1271
|
+
write**: one full-suite run here saved the memory file and the file came back without its `version` field,
|
|
1272
|
+
with the test itself green the other three times. "Saved" and "never saved" must not look the same.
|
|
1273
|
+
|
|
1274
|
+
**Consequences and boundaries:**
|
|
1275
|
+
- The fallback gives up **atomicity** for that one write, not correctness of the bytes: the same string is
|
|
1276
|
+
written either way, so a reader never sees a half-written file. What is lost is the guarantee that the
|
|
1277
|
+
replacement is instantaneous.
|
|
1278
|
+
- If the other handle holds the file with a deny-write share mode, the direct write fails too and the error
|
|
1279
|
+
propagates, exactly as before. The fallback is best-effort, not a guarantee - do not describe it as one.
|
|
1280
|
+
- Deciding to fix this was not cosmetic: this repository already treats a silently failed save as a defect
|
|
1281
|
+
(there is a CHANGELOG entry about `MEMORY_SAVE_FAILED`), and the flake was the same class of thing
|
|
1282
|
+
arriving from the filesystem instead of the code.
|
|
1283
|
+
|
|
1284
|
+
## D-047: Drafted replies produce text and stop there, the risk gate rides on the binary questions, and the assistant path masks before the script sees anything
|
|
1285
|
+
|
|
1286
|
+
**Status:** Active
|
|
1287
|
+
|
|
1288
|
+
`draft` (CLI) and the assistant tool `draft_reply` ask a decision model seven typed questions about one
|
|
1289
|
+
conversation (intent / need / action / should this get substance / risk 0-9 / money / unfulfilled
|
|
1290
|
+
commitment), refuse when the answer is money **or** risk >= 7, otherwise generate N candidate replies and
|
|
1291
|
+
rank them. The output is **text only** - there is no path from here into WeChat, no input-box filling, no
|
|
1292
|
+
key simulation, no focus stealing. `capabilities --json` declares `sendsNothing: true` alongside
|
|
1293
|
+
`writesNothing`.
|
|
1294
|
+
|
|
1295
|
+
**Reason:** the pattern comes from reading `jev-chat-jarvis` (an Android assistant that reads the screen
|
|
1296
|
+
through accessibility, decides, and drafts three replies into the input box without ever sending). Two
|
|
1297
|
+
things it does *not* do are the two things worth doing here, and both were read from its source rather than
|
|
1298
|
+
assumed: its danger level (0-9) is consumed **only** as a badge colour - it never refuses a draft, degrades
|
|
1299
|
+
or blocks - and its judgement result is **not** in the drafting prompt (`ReplyClient.draft` takes no
|
|
1300
|
+
`Analysis`), so "draft with the judgement's sense of proportion" is missing. Its "never send" is its
|
|
1301
|
+
boundary; here sending was already structurally unreachable (`TOOL_DEFS` has no send tool, D-008), so the
|
|
1302
|
+
risk this feature adds is not a mis-send but **making it easier to send a message the user will regret** -
|
|
1303
|
+
which is what the gate is for.
|
|
1304
|
+
|
|
1305
|
+
**Consequences and boundaries:**
|
|
1306
|
+
- **The gate rides on the binary questions, not on the 0-9 score.** `money` is a hard refusal; `risk >= 7`
|
|
1307
|
+
is a refusal whose advice is taken from the `action` answer ("go read the earlier history first"). The
|
|
1308
|
+
0-9 band is calibrated in wording but its **threshold is picked, not calibrated**, so it explains
|
|
1309
|
+
("risk 8/9: it has already turned into a fight") and does not decide alone. `commitment` deliberately
|
|
1310
|
+
does **not** refuse by default - "I promised and went quiet" is exactly when a draft is most useful - it
|
|
1311
|
+
constrains the prompt instead; `--gate-commitment` makes it a hard refusal, which is the trade this
|
|
1312
|
+
record exists so someone can reverse.
|
|
1313
|
+
- **The English criteria are borrowed, and that choice is unverified here.** The question set follows the
|
|
1314
|
+
reference implementation, which was calibrated against a 30-conversation Chinese set in its own
|
|
1315
|
+
(intimate-relationship) domain; the criteria are English because its `TASK.md` states the model's main
|
|
1316
|
+
training language is English. `reply_debt.py` still asks its own money/commitment questions in **Chinese**
|
|
1317
|
+
and those two are reused **verbatim**, so this repo now runs two wording conventions against one model
|
|
1318
|
+
with **no A/B run**. Recorded as an open question, not as a settled choice.
|
|
1319
|
+
- **Two input paths, and the mask asymmetry is deliberate.** The CLI reads the database itself and does
|
|
1320
|
+
**not** mask - the user ran it explicitly and the preview states how many characters go to which models
|
|
1321
|
+
(same as `awaiting`). The assistant tool masks every message through `privacyGate` and hands the result
|
|
1322
|
+
to the same script **over stdin**; Python has no redaction implementation at all (`scripts/` contains
|
|
1323
|
+
only two unrelated bit-masks), and the one implementation lives in TypeScript. `strict` mode **refuses**
|
|
1324
|
+
the tool outright rather than drafting from a body masked into "content N characters", because that
|
|
1325
|
+
produces a fluent answer built on nothing.
|
|
1326
|
+
- **`isLocalInference()` is deliberately *not* an escape hatch here**, unlike `look_at_image`. That image
|
|
1327
|
+
really does go to the local model, so "local inference" is a valid bypass there; drafting's egress is the
|
|
1328
|
+
**script's own** two remote calls (`jev_client` over HTTP, `_utils.call_deepseek` hard-wired to DeepSeek),
|
|
1329
|
+
which `aiEngine` does not touch. The first implementation copied the bypass from `look_at_image` and a
|
|
1330
|
+
test caught it: with `aiEngine=ollama` + `assistantPrivacy=strict` the transcript was handed to the
|
|
1331
|
+
script. Verified by measurement, then removed.
|
|
1332
|
+
- **Clicking a name in the panel's quick-reply menu *is* the consent.** That menu (right-click; the list comes from
|
|
1333
|
+
the `quickReplyContacts` config key) turns one click into a draft for one person - which means that conversation goes
|
|
1334
|
+
to two cloud models. So the cost is written **inside the menu** rather than discovered afterwards, and the menu
|
|
1335
|
+
deliberately does not exist in the browser fallback, where the native right-click menu (with Copy) belongs to the
|
|
1336
|
+
user. The menu is a **native** one for the same reason: a page-drawn menu cannot leave the window and the ball's is
|
|
1337
|
+
76x76, so the first attempt expanded the window to make room - which turned a quick action into "right-click opens the
|
|
1338
|
+
chat window" (the user's words). A native popup draws outside the window, so nothing has to move - and because the
|
|
1339
|
+
chat window is deliberately *not* always-on-top, expanding it now also raises and focuses it: closing a native
|
|
1340
|
+
popup can hand focus back to some other window, and the next complaint was "I do not know where the answer
|
|
1341
|
+
went" (it went into a window that was behind something else). The conversation shows a readable turn
|
|
1342
|
+
("快速回复:<name>") while what is actually sent is the explicit tool-naming request. Nothing here sends a
|
|
1343
|
+
message: the chain is structurally text-only, same as everywhere else. The menu's **last item is not a consent at
|
|
1344
|
+
all** - it hides the ball and reaches no model - and it is the one item that survives an empty contact list, because
|
|
1345
|
+
a menu of two grey lines with nothing clickable gives that user no way to dismiss the ball. It **hides rather than
|
|
1346
|
+
quits**: the exit actions stay in the tray menu, which already separates them from show/expand, and hiding is the
|
|
1347
|
+
reversible choice. The label therefore has to name the way back (`关闭悬浮球(托盘图标能再打开)`) - the ball runs with
|
|
1348
|
+
`setSkipTaskbar(true)`, so a hidden ball leaves nothing on the taskbar to click, and a label saying only "关闭" would
|
|
1349
|
+
read as "it is gone for good".
|
|
1350
|
+
- **The transcript line has one shape, and a wxid is not a speaker label.** The line the judgement and drafting steps
|
|
1351
|
+
read (`[09-23 12:20] 老王:…`) is rendered in two places - `assistantTools.transcriptLine` on the tool path (mask in
|
|
1352
|
+
TypeScript, then send the rendered lines over stdin) and `reply_debt.format_line` on the CLI path (which reads the
|
|
1353
|
+
database itself) - and they had drifted in four ways with nothing failing. The rule that came out of fixing it is the
|
|
1354
|
+
second half of the title: `senderUsername` is a raw column holding the sender's **wxid**, not a name, so neither side
|
|
1355
|
+
may use it as a label; a resolved display name (`senderDisplay`) is used when there is one, and `对方` otherwise.
|
|
1356
|
+
This was first recorded as a trade-off, on the assumption that the alternative to a wxid was `对方` and that
|
|
1357
|
+
collapsing a group's speakers would cost the ability to answer "who said that". **Measuring retired that
|
|
1358
|
+
assumption**: across 8 sessions and 179 messages from other people, `senderDisplay` held a name 174 times,
|
|
1359
|
+
`senderUsername` was guid-shaped 176 times, and the two were never equal. So the name keeps speaker distinction
|
|
1360
|
+
*and* removes the identifier, and `get_messages` was changed to use the same rule rather than left on the raw
|
|
1361
|
+
column. The rule is one function (`speakerLabel`), because "who is speaking" is the kind of thing that grows a
|
|
1362
|
+
second implementation.
|
|
1363
|
+
- **The judgement step is degradable now, and the degradation says so.** Jev's free period ended on
|
|
1364
|
+
2026-09-25; when that call fails (expired, bad key, or a dropped connection - the first failure seen here was
|
|
1365
|
+
an `http.client` read error) the tool no longer refuses to draft. It falls back to DeepSeek alone: three
|
|
1366
|
+
candidates, no ranking, and **no gate** - because the gate (money, or risk >= 7 -> no draft) exists only
|
|
1367
|
+
because a judgement exists. That is a deliberate trade the user asked for, and it is only acceptable because
|
|
1368
|
+
the output **states it**: `judged: false` plus a `judgeNote` travel with the result, the CLI prints
|
|
1369
|
+
"这次没有判断…闸门没生效" before the candidates, and the assistant tool prints the same. A fallback that
|
|
1370
|
+
looked like a normal run would be the worst outcome here, and the tests pin both directions (with a
|
|
1371
|
+
judgement `evaluate_gate` is consulted exactly once; without one it is never consulted). The consequence
|
|
1372
|
+
for availability follows: `deepseekApiKey` is now the only required key, and a missing `typesafeApiKey` no
|
|
1373
|
+
longer hides the tool - hiding something that can run would be its own kind of lie.
|
|
1374
|
+
- **The MCP surface gets a stricter boundary than the panel does, on purpose, and it applies to all four tools that egress.** `draft_reply` is on the
|
|
1375
|
+
MCP tool list (that list is *derived* from the assistant tool table minus `save_memory` and `look_at_image`, so
|
|
1376
|
+
anything added there appears there too, and a tool that cannot work on that transport has to be excluded
|
|
1377
|
+
explicitly - `look_at_image` hands its image to the assistant's model through a side channel that MCP never
|
|
1378
|
+
reads, so it used to answer "you can see it now" while shipping nothing) and an MCP call that does not pass `confirm: true` returns a **preview only** -
|
|
1379
|
+
how many messages, how many characters, which two models - with nothing leaving the machine. The panel and
|
|
1380
|
+
the WeChat bot do not need that flag: their boundary is the sender allowlist plus a user asking in a
|
|
1381
|
+
conversation only they can see. The same gate covers `who_owes_reply` (every conversation's text, one
|
|
1382
|
+
request each), `search_chats` (the question plus words pulled from the chats) and `search_semantic` (the
|
|
1383
|
+
query, then the hits) - one shared message shape in `confirmPreview()`, with each tool filling in what it
|
|
1384
|
+
actually sends. Two of them can produce real numbers for the preview by running their script's own
|
|
1385
|
+
`--dry-run`; the other two say it from their arguments, because `route_cards.py --dry-run` prints plain
|
|
1386
|
+
text rather than JSON and `semantic_search.py` has no `--dry-run` at all. An MCP client is a **third-party process** whose session nobody here can
|
|
1387
|
+
observe, so its default is "show me the cost first". The tool description states this, because a calling
|
|
1388
|
+
model that does not know it cannot ask its user.
|
|
1389
|
+
Two claims next to this were wrong and are now corrected rather than quietly dropped: `capabilities --json`
|
|
1390
|
+
reported `safety.mcpDefaultReadOnly: true` (false - that surface has contained a file writer and a
|
|
1391
|
+
model-calling tool since `export_chat` and `look_at_image`), and an earlier revision of this record said
|
|
1392
|
+
drafting was deliberately kept off MCP. It was never off; it had simply never been *checked*, because the
|
|
1393
|
+
tool names are generated at runtime (`wechat.${name}`) and a grep for the literal `name: 'wechat.` cannot
|
|
1394
|
+
see them.
|
|
1395
|
+
- **The panel only gets a copy button**, on the assistant's turns. It copies the whole reply rather than
|
|
1396
|
+
per-candidate lines: the visible reply is the model's own rendering, so guessing which lines are
|
|
1397
|
+
candidates would sometimes copy half a sentence. A failed clipboard write says so and selects the text
|
|
1398
|
+
instead of claiming success.
|
|
1399
|
+
- Group chats are out of scope (the reference implementation documents the same limit, and local group
|
|
1400
|
+
semantics are not reliable here), as is any automatic labelling: there is no gold standard for "is this
|
|
1401
|
+
draft right", so the feature records what it judged and says so rather than claiming calibration.
|
|
1402
|
+
|
|
1403
|
+
## D-055: The Vault's copy of an article is cleaned, the fetched original is not - and "which days does the copy run cover" was being decided by a marker that only one writer sets
|
|
1404
|
+
|
|
1405
|
+
**Status:** Active
|
|
1406
|
+
|
|
1407
|
+
**Decision.** `copy_to_vault` now writes the Vault's `Sources/WeChat/<day>/` copy through
|
|
1408
|
+
`strip_wx_ads_document`, which removes the known WeChat interface phrases (`继续滑动看下一个`, `轻触阅读原文`, the
|
|
1409
|
+
`在小说阅读器中沉浸阅读` family, eight in total) while leaving **two things untouched**: the frontmatter and any
|
|
1410
|
+
fenced code block. `output/biz-daily/` keeps the fetched text byte-for-byte.
|
|
1411
|
+
|
|
1412
|
+
**Where the cleaning lives is the whole point, not an implementation detail.** Cleaning in the fetch step would
|
|
1413
|
+
have been simpler and would have made the two copies identical - but `biz-daily` is the only copy of the fetched
|
|
1414
|
+
bodies, so a cleaner with a bug there destroys material that cannot be re-derived. Cleaning only inside the copy
|
|
1415
|
+
function keeps the original recoverable, at the cost of the two trees no longer matching - and that cost has to be
|
|
1416
|
+
paid **inside the copy**, because this step is re-runnable. It is not hypothetical: on 2026-09-28 a plain re-sync
|
|
1417
|
+
silently restored the residue on 15,731 files, because an earlier one-off pass had cleaned the Vault copies while
|
|
1418
|
+
the copy function was still dumb. Any future path that writes article text into the Vault must go through this
|
|
1419
|
+
function or it will undo the cleaning again.
|
|
1420
|
+
|
|
1421
|
+
**Two protections, each because the obvious version is silently wrong.** The phrases are short words:
|
|
1422
|
+
`去阅读` appears inside the title `如何去阅读一本书`, and the title is the only alignment key between a reading note
|
|
1423
|
+
and its source, so the frontmatter is never cleaned (measured: 0 of 35,060 files currently need it - the guard is
|
|
1424
|
+
for the future, and is written down as such rather than presented as a fix). `strip_wx_ads` also normalises
|
|
1425
|
+
whitespace, which is harmless for the summaries it was written for and destroys indentation in code blocks:
|
|
1426
|
+
20 files carry fences and **10 of them would have been altered** (HTML/JS samples).
|
|
1427
|
+
|
|
1428
|
+
**What it deletes is more than the phrases, and that has to be stated rather than glossed.** It is
|
|
1429
|
+
`strip_wx_ads`, the function the reading notes already use, so it also drops `______` runs, `javascript:void(0)`
|
|
1430
|
+
links, the `原创 <账号名>` byline WeChat repeats several times, and repetitions of the same token. The byline rule
|
|
1431
|
+
alone fires on **9,334 of 35,463 files**. Classifying every diff over a 300-article sample found no reworded prose
|
|
1432
|
+
- that is the part that was verified, and it is the reason it was defensible to point the cleaner at a whole
|
|
1433
|
+
article body. An earlier draft of this entry said the pass touched only phrases and whitespace; measuring the
|
|
1434
|
+
corpus disproved it, and the correction is recorded here rather than quietly edited away. The byline is page
|
|
1435
|
+
furniture and `source:` in the frontmatter still carries the account name, so the trade is acceptable - but it is
|
|
1436
|
+
text removal, and the pristine copy lives in `output/biz-daily/`.
|
|
1437
|
+
|
|
1438
|
+
**A gate that had been wrong from the start.** `--vault-sync` selected days by the `backfilled` marker in
|
|
1439
|
+
`.articles.json`. That key is written by the backfill path only, so the 12 days produced by the `daily` path (which
|
|
1440
|
+
writes `articles` but not `backfilled`) were never copied **and were never reported as skipped** - including two
|
|
1441
|
+
days, 08-24 and 08-25, with no directory in the Vault at all. The gate is now "the day has at least one `.md`
|
|
1442
|
+
besides `README.md`", which is the condition the flag was always meant to express. Measured: 187 → 199 days,
|
|
1443
|
+
33,232 → 35,463 files. The 1,755 files that kept their residue after the first cleaning pass were exactly this
|
|
1444
|
+
population.
|
|
1445
|
+
|
|
1446
|
+
**Why the notes were never affected.** Every reading note points at `output/biz-daily/...` through `local_source`,
|
|
1447
|
+
not at the Vault copy - verified across five months, 1,144 of 1,144. So none of this created a broken link, and
|
|
1448
|
+
`wiki lint` reports the same result before and after.
|
|
1449
|
+
|
|
1450
|
+
## D-054: A page can be assembled from material that was already paid for, and "no model call" is not the same as "no model wrote it"
|
|
1451
|
+
|
|
1452
|
+
**Status:** Active
|
|
1453
|
+
|
|
1454
|
+
**Decision.** `wiki compile --pages-from-cards` builds pages for concepts that have none, locally and without any
|
|
1455
|
+
API call: the definition is the `desc` the card already carries, related concepts are the other concepts from the
|
|
1456
|
+
same article (co-occurrence, median 3), the source is that article. 17,383 pages, ¥0. It was written after the
|
|
1457
|
+
model-based route was priced at **¥31** and the user called that expensive - which is the right call, because
|
|
1458
|
+
every one of those candidates is mentioned by exactly **one** article, so there is nothing to synthesise and a
|
|
1459
|
+
model would only be rewording a single sentence.
|
|
1460
|
+
|
|
1461
|
+
**The honest description is "reusing output already paid for".** The `desc` lines were written by
|
|
1462
|
+
`article_notes` when it extracted concepts from articles, so calling this "no model involved" would be wrong:
|
|
1463
|
+
the quality is that line's quality. Most read like definitions, a few are meta-descriptions about where the term
|
|
1464
|
+
appeared in the article (`免疫生态位` - "the other thing the title splits out alongside cell types"). Pages carry
|
|
1465
|
+
`summary_by: card` for exactly this reason, following the `summary_by: model` convention `user_notes`
|
|
1466
|
+
established: text that came from somewhere else must not be readable as "this is what the page's subject is".
|
|
1467
|
+
|
|
1468
|
+
**Why this is not the same trade as the earlier ¥32 rejection.** The 500 trial pages built with a model are
|
|
1469
|
+
better pages - they have a synthesised definition and a "key points" section - and they cost ¥0.9 for 500. The
|
|
1470
|
+
difference is not quality, it is what the money buys: with one source per concept, it buys prose polish that a
|
|
1471
|
+
reader cannot tell from the original sentence. That trade is worth making per page only when the user asks for it;
|
|
1472
|
+
it is not worth making 17,383 times by default.
|
|
1473
|
+
|
|
1474
|
+
**A guard that had to be added, and why the first version was wrong.** `--refresh-sources` deliberately scans
|
|
1475
|
+
**all** card directories (it fills in edges on existing pages, so more is strictly better). Reusing that same
|
|
1476
|
+
default for page building would have put chat-line concepts into `Wiki/Concepts` - the two lines are two
|
|
1477
|
+
knowledge bases, which the user asked for explicitly on 2026-09-27. The flag now requires explicit `--cards` and
|
|
1478
|
+
prints the exact commands for each line when it is missing.
|
|
1479
|
+
|
|
1480
|
+
## D-053: One concept is one node, and duplicates are merged through `aliases` rather than by rewriting links
|
|
1481
|
+
|
|
1482
|
+
**Status:** Active
|
|
1483
|
+
|
|
1484
|
+
**Decision.** `wiki compile --merge-duplicates` finds concept pages whose names are the same under a normal form
|
|
1485
|
+
(strip spaces, hyphens and punctuation; lowercase; drop an English plural tail) and merges each group into one
|
|
1486
|
+
page. It was prompted by the user's own reading of the graph - "some of them are the same thing but drawn as
|
|
1487
|
+
different ones ... I want the graph to be effective, not piled up like garbage" - and the measurement backed it:
|
|
1488
|
+
74 groups, 80 redundant pages, in shapes like `GPT 5.6` / `GPT-5.6` / `GPT5.6`, `AI skill` / `AI skills`, and
|
|
1489
|
+
`AI 工具` / `AI工具`.
|
|
1490
|
+
|
|
1491
|
+
**The other names become `aliases`, and that is the whole reason this is cheap.** 98 links in the vault pointed
|
|
1492
|
+
at names that would be deleted. Writing those names into the surviving page's `aliases` means Obsidian resolves
|
|
1493
|
+
every one of them - so **nothing else in the vault has to be rewritten**. Doing it the obvious way, by rewriting
|
|
1494
|
+
every referencing page, would have touched files across both knowledge bases for a graph-shape change, and the
|
|
1495
|
+
next merge would touch them again. Verified both directions: all 80 dropped names are aliases somewhere, and all
|
|
1496
|
+
98 links are covered.
|
|
1497
|
+
|
|
1498
|
+
**Which page survives is decided by source count, not by name.** `claude code` (411 sources) absorbs `ClaudeCode`
|
|
1499
|
+
(7). Sorting by name instead would keep the emptier page - and a test now pins this specifically, because the
|
|
1500
|
+
first version of the fixture happened to make both rules agree, so the mutation "sort by name" passed.
|
|
1501
|
+
|
|
1502
|
+
**The criterion is not the lint's.** `wiki_lint` calls two titles near-duplicates when they share a 5-character
|
|
1503
|
+
run covering 40% of the shorter one, which reports `Claude Code` and `Claude 4.8` as a pair. The merge does not
|
|
1504
|
+
use that rule and does not merge them: a false merge destroys a real concept, a missed merge just leaves a
|
|
1505
|
+
duplicate. `wiki_lint` now reads `aliases` as well - otherwise it would report the merged-away names as "not
|
|
1506
|
+
built yet, run compile with a higher limit", which is not a warning but a wrong instruction (that count fell
|
|
1507
|
+
9,421 -> 9,384).
|
|
1508
|
+
|
|
1509
|
+
**Not done, deliberately.** Nothing automatic: it is a flag, `--dry-run` shows the list first, and it is meant to
|
|
1510
|
+
be run when the user notices the graph getting noisy rather than on a schedule. Nor does it try to merge the
|
|
1511
|
+
looser "similar name" set - deciding whether `Claude Code` and `Claude 4.8` are one thing is a judgement, and
|
|
1512
|
+
that is where a decision model rather than a string rule would be needed. **That last one was tried on
|
|
1513
|
+
2026-09-28, and Jev cannot do it either** ($0.17): of the 13,367 loose pairs, 741 came back "the same
|
|
1514
|
+
concept", and sampling them (the most confident 25, then a random 12) showed essentially all are wrong
|
|
1515
|
+
(`Token 节省` vs `Token计费`, `Agentic RAG` vs `Agent面试准备`). Rewriting the instructions with
|
|
1516
|
+
counter-examples fixed those and broke the true ones in the same run (`AI skill` vs `AI skills` came back
|
|
1517
|
+
"related but different"). The question here is **literal** - same string modulo spacing, case, plurals - and
|
|
1518
|
+
Jev is a semantic model; the normal form was the right tool all along. Narrowing the 741 to "one name is an
|
|
1519
|
+
"extension of the other" left 27, and inspecting each of them by its actual definition left **two** that
|
|
1520
|
+
are genuinely the same (`TRAE IDE Linux 版` / `TRAE IDE Linux 版本`, and
|
|
1521
|
+
`100 万 token 上下文窗口` / `100万token上下文`) - merged by hand, aliased, 0 broken links. The other
|
|
1522
|
+
twenty-five were rejected on meaning: "日均Token调用量" is a rate and "Token调用量" is a total;
|
|
1523
|
+
"2026世界人工智能大会" is one year's event and "世界人工智能大会" is the series; "Token 节省" is a
|
|
1524
|
+
concept and "Token 节省技巧" is a set of methods. So the loose set stays untouched, and that is a finding
|
|
1525
|
+
rather than a gap: the graph's name-level duplication was already nearly exhausted by the 74 groups the
|
|
1526
|
+
rule could settle, and the 741 Jev proposed contained two.
|
|
1527
|
+
untouched, and that is a finding rather than a gap: the graph's name-level duplication was already nearly
|
|
1528
|
+
exhausted by the 74 groups the rule could settle.
|
|
1529
|
+
|
|
1530
|
+
## D-052: Concept pages accumulate on their own schedule, and the body's source list deliberately diverges from `sources:`
|
|
1531
|
+
|
|
1532
|
+
**Status:** Active
|
|
1533
|
+
|
|
1534
|
+
**Decision.** `wiki compile --refresh-sources` is a local, model-free step that rewrites the `## 来源` section
|
|
1535
|
+
of **existing** concept pages so they carry every source the cards currently support. It landed because the
|
|
1536
|
+
user asked how the graph grows when new articles arrive, and the honest answer turned out to be "only new
|
|
1537
|
+
nodes": `build_jobs` skips concepts that already have a page, so an article mentioning `DeepSeek` adds nothing
|
|
1538
|
+
to `DeepSeek.md`. `OPERATIONS.md` had been claiming the pages were "a ledger" while the implementation made
|
|
1539
|
+
them a snapshot; one run added 3,164 edges across 324 article-line pages and 148 across 34 chat-line pages.
|
|
1540
|
+
|
|
1541
|
+
**Three things it does not do, each because the obvious version is silently wrong.**
|
|
1542
|
+
|
|
1543
|
+
*It does not sync the frontmatter.* The body and `sources:` carry **deliberately different** names, and the
|
|
1544
|
+
live example is a chat page: `sources: [24环境专硕班]` (the card name, which is what `source_kinds_for`
|
|
1545
|
+
looks up under `output/chat-notes/`) versus `- [[会话-24环境专硕班]]` in the body (the Vault name, which is
|
|
1546
|
+
what Obsidian resolves under `Sources/Chat/`). Writing either name into the other place breaks something
|
|
1547
|
+
without erroring: put the body's name into `sources:` and 100% of chat-line tag inference stops (measured),
|
|
1548
|
+
while `relabel_pages` only ever *adds* `来源/*` tags - so the page keeps looking correct. This is a case of
|
|
1549
|
+
"the same fact in two places" where the two places have genuinely different consumers, so the rule is to say
|
|
1550
|
+
so out loud rather than to unify them.
|
|
1551
|
+
|
|
1552
|
+
*It only adds.* 322 article-line source lines have no card behind them any more (the card was rewritten, or
|
|
1553
|
+
no longer mentions the concept). They stay. That makes the page's source count **larger** than the index's
|
|
1554
|
+
citation count on 195 concepts - and the earlier reading of that gap, that the two numbers *should* match,
|
|
1555
|
+
was wrong: the index says what the cards say now, the page holds every reference it ever had. When this was
|
|
1556
|
+
measured, all 195 differed in that one direction and every extra line resolved to a real file.
|
|
1557
|
+
|
|
1558
|
+
*It skips names that cannot be written.* Six contain square brackets (Obsidian cannot resolve those either),
|
|
1559
|
+
and one has a title that itself ends in `.md`. That last one is worth remembering: `source_link_target` peels
|
|
1560
|
+
one suffix and yields `….md`, while `wiki_lint.resolve` sees the `.md` and looks for a *file* by that name -
|
|
1561
|
+
so the write would have turned the lint's "0 broken links" into 1. The card's real name is `….md.md`.
|
|
1562
|
+
|
|
1563
|
+
**Ordering is a spec, not a history.** Sources are written `sorted()`. The first draft appended new lines after
|
|
1564
|
+
the existing ones, which makes the order a function of when each line arrived - so the diff never settles and
|
|
1565
|
+
an insert lands in a different place on every machine. Measured cost of normalising: 29 extra pages.
|
|
1566
|
+
|
|
1567
|
+
**What was NOT changed.** `generate_concept` still truncates the model's *reference material* to five entries
|
|
1568
|
+
(`build_ref_lines`, a prompt-budget decision, tested), so a page may now list 301 sources while the model that
|
|
1569
|
+
wrote it saw 5. That is a real asymmetry and it is stated here rather than left to be discovered - the
|
|
1570
|
+
alternative, feeding 301 sources into the prompt, is a different change with its own cost.
|
|
1571
|
+
|
|
1572
|
+
## D-051: One Vault, two knowledge bases - and a directory list that five readers have to agree on
|
|
1573
|
+
|
|
1574
|
+
**Status:** Active
|
|
1575
|
+
|
|
1576
|
+
**Decision.** The article line and the conversation line are kept **apart on disk and together in the graph**:
|
|
1577
|
+
`Wiki/Concepts` (+ `Wiki/00-Overview.md`) is the article knowledge base, `Chat/Concepts` (+ `Chat/00-Overview.md`)
|
|
1578
|
+
is the chat one, and the conversation cards live at `Sources/Chat/会话-<name>.md` next to `Sources/WeChat/`.
|
|
1579
|
+
Both are inside the same Obsidian vault, because the request was to keep the two knowledge bases apart, not to
|
|
1580
|
+
make the person browse two vaults - and the graph is the one place where their 4% overlap is information rather
|
|
1581
|
+
than noise. The split is the user's call (2026-09-27); the parts below are the consequences that had to be paid
|
|
1582
|
+
for it.
|
|
1583
|
+
|
|
1584
|
+
**The list cannot be shared, so five declarations are pinned to one.** The set of concept directories is needed
|
|
1585
|
+
in Python (`_utils.CONCEPT_DIRS`), in `src/services/assistantTools.ts`, in `bin/weflow-cli.ts`'s `vault init`
|
|
1586
|
+
preview, and in the separately-packaged `mcp-server/index.ts` - four languages- and package-boundaries that a
|
|
1587
|
+
single constant cannot cross. Following the precedent already in this repo for the same situation
|
|
1588
|
+
(`config-keys.test.ts`, the `requiresConfirm` list), the declarations stay separate and
|
|
1589
|
+
`test/concept-dirs-agreement.test.ts` fails if any of them drifts. The two scripts that `import` the constant
|
|
1590
|
+
instead (`vault_search`, `vault_rag`) cannot drift, so what is asserted about them is the other failure -
|
|
1591
|
+
importing it and then not iterating it.
|
|
1592
|
+
|
|
1593
|
+
**Why this deserved a decision rather than a commit message: reading one directory of two is silently wrong, not
|
|
1594
|
+
broken.** Every reader here degrades by *losing half its answers* - fewer search hits, fewer concepts for the
|
|
1595
|
+
assistant, "未找到概念" from MCP - while continuing to exit 0 and print a plausible result. Nothing in the repo
|
|
1596
|
+
would have gone red. That is the same failure class this project keeps recording (a path written in two places;
|
|
1597
|
+
a directory declared and never written), and it is the reason the agreement test exists rather than a comment.
|
|
1598
|
+
|
|
1599
|
+
**A name belongs to exactly one page, and that had to be enforced in code.** Splitting the directories created
|
|
1600
|
+
11 concepts with a page on both sides on the very first run (`deepseek.md` in `Wiki/` and in `Chat/`). Two files
|
|
1601
|
+
with the same stem make a bare `[[DeepSeek]]` ambiguous: Obsidian silently picks one and the other is reachable
|
|
1602
|
+
only by its full path, which is the same failure the `会话-` prefix was introduced for on the cards - and, as
|
|
1603
|
+
there, **nothing errors**. So `build_jobs` skips a name that already has a page on the other line, and the run
|
|
1604
|
+
reports the two skip reasons separately (a re-run versus a deliberate non-duplicate), because a single "skipped
|
|
1605
|
+
N" line would read as if all of them were re-runs. The alternative - keeping both pages, one per line - was
|
|
1606
|
+
rejected: a browseable graph is the thing the user asked for, and an ambiguous link is not a link.
|
|
1607
|
+
|
|
1608
|
+
**A migrated parameter is a silent failure too.** `conceptNeighbors(pageName, wikiDirs)` took one directory and
|
|
1609
|
+
now takes a list; a caller still passing a string is not a type error at runtime, and `for (const dir of
|
|
1610
|
+
wikiDirs)` walks the string **one character at a time**, so the function answers "no neighbours" with full
|
|
1611
|
+
confidence. `tsc` covers `src/**` but not `test/`, which is where the stale call was. It now throws on a string:
|
|
1612
|
+
a wrong answer that looks like an empty answer is worse than a crash, and the same reasoning already governs the
|
|
1613
|
+
"no data" cases elsewhere in this file.
|
|
1614
|
+
|
|
1615
|
+
**What was NOT changed.** `output/wechat-vault` still holds both, and the chat side is still produced by
|
|
1616
|
+
`compile_wiki --source output/chat-notes --output output/wechat-vault/Chat/Concepts` **explicitly** rather than by
|
|
1617
|
+
teaching `compile_wiki` to iterate `CONCEPT_DIRS` - it produces one line's pages per run, and a default that
|
|
1618
|
+
writes both would mix the two corpora into whichever directory ran last. `user_notes` is unchanged: it reads a
|
|
1619
|
+
whitelist of the user's own layers, so the new `Chat/` directory is out of scope by construction rather than by
|
|
1620
|
+
an added exception.
|
|
1621
|
+
|
|
1622
|
+
## D-050: The WeChat channel's failures are classified, retried with backoff, and reported where the user looks
|
|
1623
|
+
|
|
1624
|
+
**Status:** Active
|
|
1625
|
+
|
|
1626
|
+
**Decision:** `wechatMessageService.startPolling` classifies every failure before acting on it. `errcode: -14`
|
|
1627
|
+
(or HTTP 401) means the token is invalid - retrying cannot succeed - so it is reported once, in a single
|
|
1628
|
+
unmissable line naming the remedy, and `isChannelActive()` turns false so the panel stops claiming the channel
|
|
1629
|
+
works; the loop keeps polling rather than exiting, because the local entrance is unaffected and a restart is the
|
|
1630
|
+
user's decision. Everything else is `retryable` and retried with 1s→30s exponential backoff, reset by any healthy
|
|
1631
|
+
poll. Exceptions thrown by registered message callbacks are logged rather than swallowed.
|
|
1632
|
+
|
|
1633
|
+
**Reason:** this was fixed by checking the vendor's own implementation instead of guessing -
|
|
1634
|
+
`third-party/WeKnora/internal/im/wechat/longpoll.go:151` states `ErrCode == -14 → ErrTokenExpired`, while this
|
|
1635
|
+
repository checked `-1 || 401`. The consequence of a wrong code is not an error: it reclassifies "expired" as
|
|
1636
|
+
ordinary, so the loop retried every 5 seconds forever and the user saw nothing at all. Two further differences
|
|
1637
|
+
were closed at the same time (fixed 5 s interval → backoff; `try { cb(msg) } catch {}` → logged), and one earlier
|
|
1638
|
+
claim was **retracted**: the long-poll timeout is 40 s here and 40 s in the vendor's `longPollHTTPTimeout`, so
|
|
1639
|
+
that item from the 2026-09-17 comparison was not a divergence. The reason the *reporting* half is in this
|
|
1640
|
+
decision rather than the code: the old `isChannelActive()` meant "a token was configured at startup", so every
|
|
1641
|
+
status surface kept saying the channel was fine - silence, not breakage, was the real defect.
|
|
1642
|
+
|
|
1643
|
+
**Consequences:** `isChannelActive()` now means "the channel is usable", which is what the panel actually wants to
|
|
1644
|
+
show; a fake channel without `isTokenExpired` is treated as healthy (the method is optional on the interface). A
|
|
1645
|
+
re-login does **not** heal a running daemon - it still holds the old token in memory - so OPERATIONS.md says to
|
|
1646
|
+
restart it, and the log line says so too. `assistant status --json` still derives `channelActive` from the
|
|
1647
|
+
endpoint file rather than from the live loop, so after an expiry it can disagree with the panel for as long as
|
|
1648
|
+
the daemon runs: recorded here as a known gap rather than fixed quietly, because publishing live state into that
|
|
1649
|
+
file is its own change. The live expiry path has never been observed end to end (it needs the server to send
|
|
1650
|
+
`-14`); what is tested is the classification, the backoff and a driven loop.
|
|
1651
|
+
|
|
1652
|
+
## D-049: Conversations become knowledge notes locally, the model call is the only egress, and it is the user's call
|
|
1653
|
+
|
|
1654
|
+
**Status:** Active
|
|
1655
|
+
|
|
1656
|
+
**Decision:** `chat-notes` turns recent conversations into knowledge cards (`output/chat-notes/<会话>.md`), and
|
|
1657
|
+
`wiki compile --source ./output/chat-notes` aggregates them into concept pages through the pipeline that already
|
|
1658
|
+
exists. It is two-phase in the same shape as the rest of the project: `--dry-run` reports how many conversations,
|
|
1659
|
+
messages and characters would be sent (reading only local data), and nothing leaves the machine without `--yes` -
|
|
1660
|
+
one model call per conversation, which is the only egress. What it writes is local markdown; it never sends a
|
|
1661
|
+
message.
|
|
1662
|
+
|
|
1663
|
+
**Reason:** the knowledge base had exactly one source - the article line, `output/biz-daily` - while conversations
|
|
1664
|
+
could be searched but never condensed into anything, which is the half of "a personal WeChat knowledge base" that
|
|
1665
|
+
was missing. Reusing `compile_wiki`'s input contract (frontmatter + `[[wikilinks]]`, `--source` already supported)
|
|
1666
|
+
means one concept format, one backlink mechanism, and no second aggregator to keep in step - the three shapes asked
|
|
1667
|
+
for (per-conversation cards, topic pages, people/timeline pages) fall out of one producer plus the aggregator that
|
|
1668
|
+
was already there. Two constraints follow from that choice rather than from taste: **the wikilinks are rendered by
|
|
1669
|
+
our code**, never by the model, because `scan_articles` collects every wikilink in a body and cannot tell who wrote
|
|
1670
|
+
it - a stray one would invent a concept, so free text from the model has `[[...]]` stripped; and **thin
|
|
1671
|
+
conversations get no card at all**, because a three-line chat yields a card that says "they said two things" while
|
|
1672
|
+
costing a model call and adding one contentless source to a concept page.
|
|
1673
|
+
|
|
1674
|
+
**Consequences:** running it over a wide window sends a lot of chat text - measured on this machine, 30 days is 37
|
|
1675
|
+
conversations / 2913 messages / 105,607 characters - so the preview, not a limit, is what the user steers by; the
|
|
1676
|
+
number is also why this is not offered as an assistant tool or over MCP (a model-initiated call of this size would
|
|
1677
|
+
be an egress nobody asked for). Cards are rolling snapshots (one file per conversation, rewritten with its recorded
|
|
1678
|
+
window) while concept pages accumulate: cards are the snapshot, concepts are the ledger. Fabrication is constrained
|
|
1679
|
+
in the prompt (compiler not author, contradictions stay side by side) rather than by post-hoc verification of every
|
|
1680
|
+
claim - the project keeps its judgement in a separate layer and a second implementation of "is this claim
|
|
1681
|
+
supported" was not wanted. Not copied on purpose from the reference implementation this borrowed its phrasing from
|
|
1682
|
+
(Tencent/WeKnora, read for this work): page revisions. The other half - a check over the produced pages - was recorded
|
|
1683
|
+
here as a known gap and **closed the same day** by `wiki lint` (`scripts/wiki_lint.py`, surfaced as `weflow-cli wiki
|
|
1684
|
+
lint`): it reports broken links (a `[[card.md]]` whose card is gone), orphan pages, empty pages and duplicate titles,
|
|
1685
|
+
locally and with no model calls. One judgement in it is worth keeping: it **separates "a concept the pages link to
|
|
1686
|
+
that has no page yet" from a break** - on the first real run that was 38 expansion candidates against 0 breaks -
|
|
1687
|
+
because reporting both as "40 dead links" teaches the reader to ignore the tool. A second judgement came from the
|
|
1688
|
+
first run too: card-side links count as inbound links, otherwise every page is reported as an orphan (the pages
|
|
1689
|
+
mostly link forward to concepts that do not exist yet, so page-to-page inlinks are nearly empty by construction).
|
|
1690
|
+
|
|
1691
|
+
## D-048: There is no in-process plugin loader; extension is in-repo, and the boundary for outside code is MCP
|
|
1692
|
+
|
|
1693
|
+
**Status:** Active
|
|
1694
|
+
|
|
1695
|
+
**Decision:** Code that extends this project is added to this repository (fork + PR). Code kept outside it reaches the
|
|
1696
|
+
project only through MCP or the CLI's `--json` surface. No plugin directory, no adapter registry, no public import
|
|
1697
|
+
surface (`package.json` has `bin` and nothing else). `docs/EXTENDING.md` is the checklist for the in-repo path and states
|
|
1698
|
+
this position rather than leaving it to be inferred.
|
|
1699
|
+
|
|
1700
|
+
**Reason:** An in-process plugin would hold exactly the reach of the code it joins - the decrypted database path, the
|
|
1701
|
+
configuration (which contains every API key), the filesystem, and the message channel. The standing rule for this
|
|
1702
|
+
project is that sending, the assistant, MCP and cloud AI each keep an *explicit* permission boundary; a plugin loader
|
|
1703
|
+
removes the boundary by construction rather than crossing it, and no amount of manifest-declared "capabilities" repairs
|
|
1704
|
+
that, because the plugin is not sandboxed. MCP is the alternative that does keep a boundary: it is enumerable
|
|
1705
|
+
(`capabilities --json` lists what is exposed), scoped (the four tools that send user data to cloud models return a
|
|
1706
|
+
preview unless the caller passes `confirm: true`), and switchable off without touching this repository. The cost is
|
|
1707
|
+
accepted knowingly: third-party code cannot add a tool, and anything a plugin loader would have made convenient now
|
|
1708
|
+
needs a PR. The roadmap keeps a plugin/adapter mechanism as a future item with its own constraint already written down
|
|
1709
|
+
(extensions must not read configuration, databases or arbitrary files directly, and must be individually disableable) -
|
|
1710
|
+
so the door is not nailed shut, but nothing about it is implemented, and `docs/EXTENDING.md` lists it under "what does
|
|
1711
|
+
not exist yet" so a reader cannot mistake the gap for an oversight.
|
|
1712
|
+
|
|
1713
|
+
**Consequences:** Adding a capability means editing this source tree, which is why the registries were made declarative
|
|
1714
|
+
and guarded (`test/tool-registry.test.ts`, `test/config-keys.test.ts`) - a framework that can only be extended by
|
|
1715
|
+
editing it had better make "did I edit all the places?" answerable. The last unguarded extension point - the CLI's
|
|
1716
|
+
interactive menu, where a menu entry with no `switch` case does nothing when picked and `runCmd` silently no-ops on a
|
|
1717
|
+
renamed command - was covered on 2026-09-25 by `test/cli-menu.test.ts`, so every recipe in `docs/EXTENDING.md` now
|
|
1718
|
+
names the test that catches a missed step. Should a loader be built later, these guards are the parts that would have to
|
|
1719
|
+
be re-derived for the plugin path rather than deleted.
|
|
1720
|
+
|
|
297
1721
|
## Decision Template
|
|
298
1722
|
|
|
299
1723
|
|