* feat(memhub): add model usage audit and cost controls Co-authored-by: Cursor <cursoragent@cursor.com> * fix(memhub): typed source authorization state and managed-key registry ownership Closes #29 Issue #29 surfaced as "WorkBuddy plugin cannot be uninstalled / source sync cannot be turned on", but it is two independent failure domains that were both being reported as a single opaque `desktop_action_failed`: 1. Source sync domain: the UI could not tell whether a source failed to sync because the grant was missing, revoked, or because the source identity or root path changed. Every failure collapsed into one error, so the UI could only say "failed". 2. Host plugin install domain: WorkBuddy shares three registry files (`known_marketplaces.json`, `installed_plugins.json`, `settings.json`) with the host. MemHub recorded a whole-file hash at install time and then refused to uninstall whenever the host (or a reformat) changed any byte anywhere in the file, even in entries MemHub does not own. What changes Domain errors (new `packages/host-bridge/src/domain-error.ts`) - Closed allowlist `DOMAIN_CODES`: grant_required, source_identity_changed, host_uninstall_modified_managed_value, host_uninstall_unknown_owned_file, host_uninstall_needs_review, host_install_journal_invalid, host_install_recovery_required, host_permission_storage_unsafe. - `DomainError` + `domainCodeOf()` so callers stop string-matching on `Error.message`. `diagnosticDomainError()` emits only the code and a repo-relative safe stack, never message text, so diagnostics cannot leak paths or payloads. - Re-exported from `runtime-control` (host-bridge owns the type to avoid a dependency cycle); desktop `observeDesktopAction` now uses `domainCodeOf(error) ?? fallbackCode`. Source authorization state - `SourceAuthorizationState` enum: ok / missing_grant / revoked_grant / source_identity_changed / source_root_unavailable / candidate_changed, plus `SourceGrantStore.authorization_state()` which is read-only and fails closed. - `/api/v1/sources` now returns `authorization_state` per source; contracts and the web app render a concrete next action next to the sync switch instead of a generic failure. - `_change_source_sync` distinguishes sync_prepare_failed, sync_config_write_failed, sync_resume_failed, sync_watcher_failed (409 with the code as detail) while keeping the existing rollback behaviour. - Capture refuses silently but now records a fail-open rejection marker (`sync_disabled` / grant_missing / grant_revoked) that counts occurrences in a time window and carries no payload, path, or session content. WorkBuddy registry ownership (journal v1 -> v2) - Ownership is now the value at MemHub's own key, not the whole file. Added `canonicalJson`, `registryManagedDigest`, `registryWithoutOurs`, `registryHoldsOnlyOurs`. - Install: the plan records `keyPath` + managed digest; apply compares the managed value (CAS) instead of the whole file; the journal writes both the v1 `installed_hash` and the v2 `installed_managed`. - Uninstall: `resolveRegistryOwnership()` compares the v2 digest, falls back to the v1 hash and `isManagedRegistryEntry`, and otherwise produces a review finding instead of throwing. `managedRegistryRemoval()` deletes a file MemHub created when it still holds only our entry; removes just our key when the host has added its own content; and restores recorded bytes only when the rest of the document is unchanged, otherwise patches the current document. - v1 journals migrate: a whole-file hash mismatch no longer blocks uninstall. Tests - `packages/host-bridge/src/domain-error.test.ts` (5): code is carried, message never reaches diagnostics, no guessing on unclassified errors, refuses to parse codes out of free text, allowlist is closed. - `workbuddy-plugin-installer.test.ts` (34 total): uninstall survives a host reformat, removes only our key when the host changed something else, asks for review when a managed entry was changed, migrates a v1 journal, keeps a registry the host now uses, installs over a post-plan change. - `apps/core/tests/test_source_authorization_state.py` (12): every state, fail-closed ordering, capture rejection names, marker contains no secret or path, recording never raises. - `apps/web`: authorization note rendered for revoked grant and hidden when ok. Verification - host-bridge WorkBuddy suite 34/34; domain-error 5/5. - apps/web 1906 tests across 105 files pass. - pnpm -r typecheck clean; biome check clean on touched files. - core-fast: 232 failed / 4131 passed, identical failure count to the clean baseline (232 failed / 4119 passed) with +12 from the new tests; the failures are pre-existing and unrelated (confirmed by stashing and re-running). * feat(memhub): add a start/stop switch for model sedimentation Shipped builds gave the user no way to stop model calls: the model content permission switch is gone (removed with its endpoint in #20) and there is no pause or remove entry either, so a configured model keeps turning conversations into memories and keeps costing money. Adds a user switch in settings: stopping keeps the model configuration and the already imported conversations, leaves queued work pending instead of failing it, and stops new work, enqueueing and backfill so nothing further reaches the model. Starting again resumes the same work without re-entering a key. The flag lives in Core's own user config (config.json), so no schema migration is needed, and the supervisor takes it as an optional callback so every existing caller and test keeps its current behaviour. * test(desktop): keep host-bridge mocks whole and assert the review contract The memhub CI job failed four desktop tests after the issue #29 change. Both were test-side contracts that the new ownership model intentionally moves, not regressions in the fix itself. 1. `host-control-ownership.test.ts` mocked `@one-memory/host-bridge` with a whitelist that carried only `RecallBroker`. Main now imports `domainCodeOf`, which runtime-control re-exports from host-bridge, so the mock handed back undefined and every error path that called it threw a TypeError instead of the error the test was asserting on. The mock now spreads the real module and overrides only `RecallBroker`, which is the part it was written for. 2. `host-permissions.test.ts` asserted the pre-#29 contract, where a change anywhere in a shared registry made uninstall throw `host_uninstall_modified_files`. Ownership is now the value at our own key, so a change to that value is reported instead of guessed at: the plan schedules no removal, names the file in a review finding, and applying it refuses with `host_uninstall_needs_review`. The test keeps the guarantee it existed for, that those bytes are never overwritten, and now also asserts that apply refuses, so the safety is checked at both steps. Verification: full desktop suite 38 files / 436 tests pass, `pnpm -r typecheck` clean, biome reports only two pre-existing warnings on lines this change does not touch. * fix(logging): keep info diagnostics actionable Co-authored-by: Cursor <cursoragent@cursor.com> * test(memhub): register the sedimentation switch test in the core test inventory New core test files must appear in TEST_INDEX.md and in the classification inventory's expected count and digest, and the backfill case needs a settled turn because an unanswered user tail is not backfill material. * feat(diagnostics): expose build info and fullscreen logs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(settings): align help controls and tighten spacing Co-authored-by: Cursor <cursoragent@cursor.com> * release: prepare MemHub 1.0.4 build 8 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(memhub): clear pre-existing dev lint errors and flaky SDK dedup tests Three things blocking CI that came in with dev and are unrelated to the sedimentation switch itself: - biome errors in DebugLogPanel.tsx (import order) and ModelUsageView.tsx (refreshToken is an intentional refresh trigger, annotated like the existing modelVersion trigger in R6Settings). - test_extractor_prompt_override.py's fake LLM now accepts keyword args the real pipeline passes since the usage audit landed (audit_stage). - the fake embeddings in the intention dedup tests used the process-random hash(), so two different texts collided roughly one run in 32 and were misread as duplicates; switched to a deterministic md5 index. * fix(cli): count recall characters as code points A recall whose hits contain an emoji outside the BMP was reported as response_invalid even though the data and the search were fine: the CLI cross-checked the response by summing String.prototype.length, which counts UTF-16 code units, against Core's used_characters, which Python's len() counts in code points. One emoji is two units here and one character there, so any hit set with an emoji failed the check. The sibling validator in host-bridge (context-protocol.ts) already counted code points, so the CLI was the odd one out rather than a deliberate choice. - validateContextResponse now sums through characterCount(), which counts code points, matching Core. - Two regression tests: an emoji hit whose used_characters follows code points must stay grounded, and the same response counted as UTF-16 units must still be refused, so the cross-check is not simply loosened. - The contract now states used_characters counts Unicode code points and is not an encoding length, so a client in another language does not repeat the comparison. Verified by reverting the source change: the first test fails on the old implementation and passes with it. Closes #30 * fix(web): keep the model recovery entry reachable while a probe is pending Closes #40 A pending model probe fenced the configuration editor, and the only recovery entry ("核对测试结果") lived inside the area the fence kept shut. Reaching it required opening the form; opening the form required having recovered. People with a stuck probe could not change or repair their model at all. The fence itself was doing its job and still is. What was wrong is that refusal also hid the way out. R6Settings - openConfiguration() now opens the model area whether or not the editor accepts, so the recovery entry inside it is reachable. editProfile() and add() still return false while the fence is up; nothing about the protection changed. - The refusal notice is reworded to match: the configuration cannot be changed yet, and the next step is down in the area that just opened. Saying "cannot be opened" was both untrue now and unhelpful before. ModelSettings - A refused open is remembered instead of dropped. Once the fence clears, the intent is replayed, so the person lands in the form they asked for. Without this they would get a blank new-configuration form the moment the probe reconciled, which invites a duplicate entry. - Replaying only fills fields in. It never sends a request, so no test is re-sent implicitly; "核对测试结果" remains the only thing that does. Tests - R6Settings: with a current model and without one, the recovery entry is reachable while the editor is fenced, and the refusal is still reported. - ModelSettings: after reconciling, the form reopens on the profile that was asked for, and the probe is sent exactly once. - Both new R6Settings cases and the ModelSettings case fail on the previous code. * perf(runtime-control): decide runtime reuse before staging the payload Every App launch copied the whole 373MB bundle into a temp directory and hashed it twice before asking whether the selected runtime could be reused. On a machine that already runs this build the answer is yes and all of that work is thrown away: measured on a release Mac bundle, 6.9s to copy and about 3.8s of hashing (fileDigests, then verifyInstalledDistribution over the same tree). installPackagedRuntime now describes the payload in place first. The staged tree is a byte for byte copy of resources/{core,host-bridge,runtime,web} plus two generated launchers, so the same relative paths and digests can be read without copying anything. reuseSelectedSameBuild takes that description instead of a staged directory, and only a payload that is going to be installed is ever staged. The decision itself is unchanged: it still compares every file digest except bin/node, still repairs the packaged launcher when the App moved, and still raises installed_version_conflict or distribution_hash_mismatch exactly as before. Nothing was replaced by a cheaper fingerprint, because a wrong reuse is what those errors exist to prevent. Hashing also runs through a small worker pool now; the digests are identical and the manifest keeps its sorted key order. New test: describeBundlePayload must equal the manifest the staged copy produces, so moving the decision ahead of the copy cannot decide something different. Verified read-only against the installed release bundle: 1534 files, identical file sets, zero digest mismatches. * perf(desktop): raise a startup window before the runtime and Core come up Opening the App currently shows nothing at all until initialize finishes. On this machine that is 55.6s from desktop.initializing to desktop.ready (about 19s installing and starting the managed runtime, then about 36s for Core to become ready), and window.requested is logged after desktop.ready. A launch that slow with no window reads as a launch that failed, and people click the icon again. startDesktopLifecycle now takes two optional hooks. showStartupWindow runs before initialize and loads a placeholder; revealStartupWindow runs after initialize and swaps that same window onto the real app. Because the window is reused rather than replaced, there is no second window and no flash. The placeholder is served from the app protocol as /__startup, in memory, not as a file in the web bundle. It performs no IPC: it renders before initialize has registered a single handler, so anything it asked for would fail and leave the page in an error state for the whole wait. That route is also reserved, so a file of the same name inside the bundle can never be reached through it. Raising the placeholder is best effort. It only fills a wait that would otherwise be blank; failing to draw it must not stop the services or the real window. Tests: five lifecycle cases cover the ordering (placeholder before initialize, reveal after, reuse of the same window, nothing for a background launch, and a real window when the placeholder was closed or could not be raised). The four suites that mock ./app-protocol now spread the real module instead of whitelisting it, which is what broke when the protocol gained exports. host-control-ownership gains the BrowserWindow methods the placeholder path uses, and its "no window" assertion now names the placeholder, since a placeholder window is expected there by design. * fix(core): stop the sedimentation switch failing its own request Turning the switch off answered "停止未能确认,模型可能仍在整理记忆,请重试" every time. The value was saved and the error was a lie the endpoint told afterwards: `put_sedimentation` logged `memory_sedimentation.toggled` with `details={"enabled": ...}`, `enabled` is not in the diagnostic allow-list, and `_details` raises. That ValueError escaped the endpoint, so the request is recorded in this machine's log three times as `api.request.failed` / `core.request.unhandled` with `diagnostic_detail_key_invalid`, and the UI took the 500 as an unconfirmed stop. Because the raise sits between the save and the resume, starting again was broken the same way: `resume_allowed()` never ran, so work parked by a stop stayed parked. Three changes, in the order they matter: 1. `enabled` is now a legal detail key. It is a bounded boolean, the same shape as `spawned` and `index_complete`. 2. `diagnostic_event` no longer lets a rejected payload reach the caller. It already swallowed `OSError` with the comment "Diagnostics must not become a new product outage"; an unlisted key is the same failure seen from the caller, and it is exactly what turned every external recall into a 500 and then killed the process last time. The payload is still wrong and still caught in CI — now by every module rather than five. 3. `test_diagnostic_payload_keys.py` walks every module instead of a hand-picked list. The two payloads that caused real outages were in files nobody thought to add to that list; the only shape of the check that holds the line is "every module, always". This file was already failing on dev with `api.py:3673: unlisted details key 'enabled'` — the bug was known to the suite and shipped anyway. `DiagnosticLog.write` still raises, so the rejection itself is unchanged and still tested; only the callers are protected. * fix(core): let the drain claim a manual note's job A memory added by hand stayed on "原话已保存,等待整理" indefinitely, with `task_id: null`: the note's durable job was never claimed, so no task was ever created for it. On this machine the job is still `pending` with `attempts=0` more than eight hours after it was saved, while the write gate admits its source without complaint. The drain had two different answers to one question. `source_writes_permitted` is `_source_writes_on`, which admits `one-memory-manual` by its grant. The claim gate was `_config_store(app).sync_enabled`, the per-app toggle, and `one-memory-manual` is not a connected app: it never appears in the configuration document, so that predicate answers False for it. The job survived the `enabled_sources` prefilter and was then dropped row by row, on every tick, forever. Nothing ever failed and nothing ever ran. The claim gate now asks the same question the write gate asks. Admission is one decision; giving it two predicates is what parked the work. Two tests pin it. One builds the drain as the composition root does and shows the job is claimed, and shows the shipped wiring stranding the same job, so the regression is visible if the toggle comes back. The other records the asymmetry itself: `_source_writes_on` is True for the manual source while `sync_enabled` is False, which is why the pair could disagree at all. No migration and no reinstall: existing pending jobs are picked up on the next drain tick. * fix(memhub): make sedimentation pause stop model dispatch Co-authored-by: Cursor <cursoragent@cursor.com> * feat(memhub): schedule memory writes by event time Co-authored-by: Cursor <cursoragent@cursor.com> * fix(core): answer the sedimentation switch before the backlog walk Turning "用模型沉淀记忆" back on wrote the choice to the store and then awaited `resume_allowed()` inside the PUT handler. That call is not a nudge: it runs maintenance over every unfinished task and then a whole-queue scan, and the bounded scan waited -- holding the scan lock -- for the queue to drop under its cohort ceiling. With a real backlog the queue never drops, so the request never returned: one toggle in the field is still open 20+ minutes later with no `api.request.completed`, while the renderer gave up at its 30s timeout and showed "启用未能确认,仍显示上次确认的状态;请重试。" for a switch that had in fact been saved. The choice is the durable fact; the walk is follow-up work: - The handler now stores the choice and returns it, and starts the resume as a watched background task (same idiom as the other Core background walks). Repeated toggles reuse the running walk instead of stacking. - A bounded scan now stops at its ceiling instead of sleeping under the scan lock waiting for capacity, which also un-starves the periodic scan that was queued behind it. It reports `queue_saturated` rather than `no_tasks`, so the status stops claiming there is nothing to do while hundreds wait. Verified on the field log first: the failing toggle is the only request in the window with `api.request.started` and no `api.request.completed`, and its trace shows `memory_sedimentation.toggled` succeeded 6ms in at `pending_count` ~500. New tests fail on the old code (timeout) and pass now. * fix(core): stop a blank author from erasing a whole conversation A user turn whose host did not say who wrote it carries `written_by=unknown`. The write binding's allow-list had every other role/author pair but not that one, so verifying the context raised `hy_memory_write_verification_context_scope` and the task ended as `model_content_outcome_unknown` after two attempts: a memory the model may well have written, that can never be confirmed, and a conversation that is never sedimented again. This is not a rare shape. On this machine 1078 user records across 1002 WorkBuddy conversations are `unknown`, and the failures were 42 per 15 minutes in the field log — every one of them a task reporting `result_count=0` while 402 sat pending. The author is missing, not the turn: the record is still inside the same conversation, workspace and source, which is what the binding actually checks, and the turn assembly already sent it. Accepting it here puts the verification back in agreement with what was sent, the same reasoning that added ("user", AGENT) for sub agent questions. New test fails on the old code at hy_source_context.py:76 — the same frame as the field stack — and passes now. * fix(core): stop an abandoned read reporting a failure nobody owns `in_read_pool` shields the wrapped worker, so a lane that runs out of budget gives up on the answer without yanking a query out from under SQLite. The caller cancels; the read runs on. When such an abandoned read then failed, nothing awaited its future any more: `shield` drops its inner callback as soon as the outer future is cancelled, so the inner future finished with an exception that nobody retrieved. asyncio reports that at GC, and the loop handler files it as `core.loop.task_exception` -- a red error sitting next to a lane that had already degraded on its own budget and already said so. On this machine that is the `raw_context_timeout` pair in the field log: a Future, not a Task, carrying TimeoutError('raw_context_timeout'), reported three times in half an hour. The lane result itself was right; only the diagnostics were arguing with themselves. Retrieving the outcome on completion keeps the diagnostics honest without hiding anything: awaiting the read still raises for a caller who waited, which the second case pins down. The new case fails on the previous code (it reports the TimeoutError) and passes now. * fix(core): one task with no inputs no longer strands every unknown task `MemoryTaskRunner._request` falls back to `chunk[0]` when no record is a person-written user turn. A task whose inputs are gone yields an empty chunk, so that fallback raised IndexError -- an error no caller can read. A reconcile pass catches domain errors per task and moves on, so IndexError did not skip the task: it ended the pass, and every unknown task behind it stayed unknown while the retry backed off 1, 2, 4 ... 128 and gave up. On this machine 68 of 303 unknown tasks had no inputs, which is exactly the `reconcile_unknown / error_type=IndexError` backing off in the field log. Those 68 cannot be reconciled either way -- there is nothing to ask HY about -- but the 235 that can must not be held hostage to them, and this is what stands between a task whose model write may already have landed and its confirmation. An empty chunk is now a domain error, so the pass skips the task and continues. Also folds in one formatting hunk left over in this file from the previous change, so the formatter is clean again. The new case fails on the previous code at memory_processing.py:1036 with the same IndexError the log shows, and passes now. * chore(memhub): bump release build number to 10 The installer refuses a package whose bytes differ while its build number stays the same, so the four Core fixes on dev need their own number to be installable over the 1.0.4 / build 9 package already on the machine. * fix(core): retire a window whose remaining rows FIFO refuses The event-time window asked only whether its ten-minute range still held *some* pending row before deciding the anchor was still active. A pending row that FIFO holds back is not claimable: it may be blocked by an earlier sibling of the same Harness whose own event time sits outside the range, and that sibling can never be claimed from inside this window. The anchor then stayed pinned forever and the whole queue starved. Live shape on one machine: the anchor sat on 2026-10-07T03:24Z while the four pending rows inside it were blocked by 929/930/1243/2347 earlier workbuddy jobs, the earliest of them dated 2026-03-12. Completions stopped at 09:59 and 3.8k queued turns sat at zero output; restarting does not help because the anchor is persisted. The contract (docs/contracts/memory-event-time-scheduling-v1.md) retires a window once every *eligible* pending/running row in the range has settled or parked, and re-anchors on the next oldest eligible job. Reuse the claim's own predicate — free lease, no busy Harness, no earlier unsent sibling — so "the window is busy" and "the window can still be worked" finally agree. Anchor selection and in-window selection already used it; only the liveness check did not. * fix(core): a refused source context is a failure, not an unknown write Issue #44: an OpenCode import carried `role=user, written_by=unknown`. The role/author set now admits it, but the row still never moved: `HyWriteBindings` is built inside `ingest`, and the invocation marker is recorded *before* that call, so the refusal arrived with the marker already set. The classifier checked `hy_invoked` first and parked the task as `unknown` — a state that means "HY may hold a write we cannot confirm" about a request that provably never reached HY. The reason stayed off the row, the task could not be re-driven, and the queue behind it stopped. `hy_memory_write_verification_context_scope` is raised from that construction alone, and `_check_source_context` (which does run after the call) raises `hy_memory_evidence_changed` instead, so the code is unambiguous: nothing was sent. It joins `PRE_SEND_REFUSAL_CODES`, and both classifiers now test that set before the marker: the durable path settles `failed` with the marker cleared so reclaim does not treat a row HY never saw as possibly-sent, and the task path fails with the code instead of marking unknown. Regression covers both directions: a pre-call refusal must end `failed` with `hy_invoked=0`, and a post-call refusal (`authority_invalid`) must still be `unknown` — the fix narrows the unknown set, it does not widen the failure set. * chore(memhub): bump release build number to 11 Carries the window starvation fix (dc0ba49) and the pre-send refusal fix (0df84c0). A new build number keeps the package distinguishable from build 10 and lets it install over the current one. * refactor(memhub): simplify durable memory scheduling Co-authored-by: Cursor <cursoragent@cursor.com> * chore(memhub): bump release build number to 12 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(security): parse diagnostic stack paths linearly Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): sync sedimentation switch status to home (#51) --------- Co-authored-by: alvinfei <alvinfei@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: kristinhe <kristinhe@tencent.com>
1323 lines
70 KiB
Python
1323 lines
70 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Coding Memory - Extractor
|
||
|
||
一次 LLM 调用,对同一段轨迹产出双层草稿:
|
||
|
||
- 经验层 experiences(有价值才抽):task / search_keys / solution /
|
||
boundary_envs / confidence。价值门槛严格,rename a file 这种琐碎操作不该成
|
||
memory;只抽取未来可能复发的有价值问题。
|
||
- 事实层 fact(模型可返回 0 或 1 条):statement / files。statement 是这次会话的
|
||
小结——用户要做什么 / 大致怎么做的 / 结果如何。模型未产出时是否存原文 fallback
|
||
由 `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` 控制,默认关闭。
|
||
|
||
复用 LLMConfig.model(与 chat extractor 共用同一 LLM 配置)。
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import time
|
||
from typing import List, Optional, Dict, Any, Tuple
|
||
|
||
from ..agent.llm_provider import LLMProvider
|
||
from ..pipelines.base import ChatMessage
|
||
from ..utils.lang_detect import is_chinese
|
||
from .preproc import (
|
||
extract_files,
|
||
keep_last_k_user_turns,
|
||
prepare_extractor_messages,
|
||
)
|
||
from .judge import _store_coding_log
|
||
from .types import CodingMemoryDraft, WorkingFactDraft
|
||
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _resolve_configured_temperature(llm_provider) -> Optional[float]:
|
||
"""从 MemoryConfig.llm 解析温度;None = 请求里不带该参数。"""
|
||
config = getattr(llm_provider, "config", None)
|
||
llm = getattr(config, "llm", None) if config is not None else None
|
||
if llm is not None:
|
||
if getattr(llm, "omit_temperature", False):
|
||
return None
|
||
temp = getattr(llm, "temperature", None)
|
||
if temp is not None:
|
||
return float(temp)
|
||
inner = getattr(llm_provider, "_llm_config", None)
|
||
temp = getattr(inner, "temperature", None) if inner is not None else None
|
||
return float(temp) if temp is not None else None
|
||
|
||
|
||
# subject 是等值比较的硬键,一句话说明抽取跑偏了,按"没抽到主体"处理。
|
||
_MAX_SUBJECT_CHARS = 80
|
||
# details 存原文,要装得下一份完整计划表 / 配置块,但不能让单条事实吃掉整个提示词。
|
||
_MAX_DETAILS_CHARS = 4000
|
||
# 兜底 statement 是检索向量,太长会被无关内容稀释;一两段用户原话足够。
|
||
# 同一个理由也约束正常路径的长度:提示词把 statement 的软上限定在 1200 字符左右,
|
||
# 靠"给定量写成数据而非对话原句"来达到,不靠砍覆盖 —— details 不进检索向量,
|
||
# statement 是检索命中后最主要的可读字段。
|
||
_VERBATIM_STATEMENT_CHARS = 600
|
||
|
||
|
||
# ================================================================
|
||
# Prompt
|
||
# ================================================================
|
||
|
||
EXTRACT_PROMPT = """\
|
||
You extract durable WORKING memories from a task-execution session segment.
|
||
A "working" session is any hands-on task where the user operates a computer /
|
||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||
refactoring, building, deploying, API/library usage) AND system & operations
|
||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||
& folder operations, installing/configuring software, environment/device setup,
|
||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||
|
||
You produce TWO layers from the SAME segment:
|
||
|
||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||
boundary_envs) bundle.
|
||
|
||
FACT (episodic, "what happened this time") — EXACTLY ONE record of THIS
|
||
session: what the user wanted, how it was gone about, how it came out. No
|
||
value bar, but there is never more than one.
|
||
|
||
The two layers answer different questions and are stored separately. A method
|
||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||
only true for this system / this project / right now — goes in the FACT.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||
═══════════════════════════════════════════════════════════════════
|
||
A memory MUST satisfy ALL of:
|
||
(a) Solves a problem likely to recur for the same user
|
||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||
health, how to set up a scheduled job)
|
||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||
|
||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||
CONCLUSION is reusable. Explicitly extract:
|
||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||
- Investigation outcomes, INCLUDING negative / not-found results
|
||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||
the same fruitless search, so it clears the bar.
|
||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||
would save future effort, keep it.
|
||
|
||
DO NOT extract:
|
||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||
"git status", "run ls once")
|
||
- One-off issues already resolved with no transferable lesson
|
||
- Information that is already in the repo/system (don't duplicate package.json)
|
||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||
- The act of ASKING THE USER for missing information. Requesting a parameter,
|
||
listing what is unknown, or eliciting required fields is conversational
|
||
behaviour, not a method: every agent already knows how to ask a question, so
|
||
there is nothing to look up later. Never emit an experience whose task or
|
||
search_keys are shaped like "how to ask for X", "gather the missing Y",
|
||
"eliciting required fields", "what to clarify before starting". Note this
|
||
stays out of EXPERIENCES only — that the session ended waiting on the user
|
||
is real state and belongs in the FACT.
|
||
|
||
If the segment made no substantive progress because it stalled waiting on the
|
||
user — the assistant only asked for parameters, or was blocked before doing any
|
||
real work — emit "experiences": [] and write the FACT alone. Nothing was
|
||
learned yet, so there is no method to store. This matters more than it looks:
|
||
an experience distilled from a stall reads as a legitimate skill on retrieval,
|
||
and a later segment of the same task will match it and ask the user again
|
||
instead of proceeding, so the stall reproduces itself for the rest of the
|
||
session. Judge substance by what the tools actually did, not by whether the
|
||
turn produced fluent text.
|
||
|
||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||
want this written down instead of figuring it out again, keep it. A multi-step
|
||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||
just code) usually qualifies. Only drop it if it is a single command any user
|
||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||
remembering.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
EXPERIENCE FIELDS
|
||
═══════════════════════════════════════════════════════════════════
|
||
|
||
task — Query-shaped name; phrase as how a user would later ASK.
|
||
"publish a release to the xx platform" not
|
||
"publish_release_internal"
|
||
Self-contained (no "this", "the file", "we").
|
||
|
||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||
solution that `task` alone wouldn't match. Phrase as questions
|
||
or topics future users would ASK ABOUT. Distinct from task.
|
||
Omit if task already covers everything.
|
||
|
||
solution — Complete, self-contained content the user actually needs:
|
||
commands, credential locations (NOT credential VALUES),
|
||
file paths, ordered steps, gotchas, decision rationale.
|
||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||
forms a coherent task; split into multiple memories only when
|
||
the user did unrelated tasks.
|
||
|
||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||
runtime / SDK / library + version
|
||
API endpoint or method signature
|
||
external service / middleware version
|
||
env var NAME (not value)
|
||
config file path
|
||
host / cluster / region identifier
|
||
If solution is environment-agnostic, leave as "" (empty).
|
||
Purpose: when the underlying SDK/middleware upgrades, future
|
||
consumers can inspect this to decide if solution still applies.
|
||
|
||
confidence — 0.0 to 1.0
|
||
1.0 user explicitly stated this as a rule / instruction
|
||
0.7 inferred with good evidence from tool actions
|
||
0.5 weakly inferred; one observation; uncertain
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||
═══════════════════════════════════════════════════════════════════
|
||
|
||
You write ONE fact for the whole segment — a recoverable state record for this
|
||
session, not a polished retrospective and never a list of separate findings. A
|
||
future agent must be able to resume from it without mistaking a suggestion,
|
||
inference, or partial search for something verified.
|
||
|
||
subject — WHO or WHAT this session is about: the single entity the work was
|
||
performed on or for. One short noun phrase, no sentence, no verb.
|
||
Pick the most specific entity that the session is *about*, not the
|
||
tool it used:
|
||
a person's name when the session serves one named person
|
||
("Dylan", "Matthew")
|
||
the file / module / symbol when the work targets code
|
||
("auth/middleware.ts", "parse_plan_text")
|
||
the service / host / device / drive for ops work
|
||
("D:\\WXWork", "redis-cache-prod", "ci.yml")
|
||
This is a HARD KEY, not prose: two sessions about different people
|
||
or different files MUST get different subjects, and two sessions
|
||
about the same one MUST get the same string, spelled identically.
|
||
Prefer the exact identifier as it appears in the trace over a
|
||
description of it. If the segment genuinely has no single subject,
|
||
output "".
|
||
|
||
Why it matters: subject is the gate that decides whether a later
|
||
session is allowed to overwrite this one as "the same task,
|
||
continued". Get it wrong and one person's state silently replaces
|
||
another's.
|
||
|
||
statement — A compact record of THIS session. Two parts, and the second is the
|
||
one that gets lost.
|
||
Preserve task-scoped givens and concrete constraints instead of
|
||
replacing them with a generic evaluation of the outcome. State
|
||
each rule once: repeated instruction text competes with the data
|
||
the memory must retain.
|
||
|
||
PART 1 — what happened, about 300 latin characters (~150 CJK).
|
||
Start with the specific goal and object ("reclaim disk space taken
|
||
by D:\\WXWork", "locate the new_file_search folder"), not a generic
|
||
"fix a bug" / "find a file". Then roughly what was inspected, tried,
|
||
or changed. Then how it ended, with the semantics of exactly one of:
|
||
completed, partially completed, failed, blocked, or
|
||
still awaiting verification. Include negative results and dead ends
|
||
without overstating them.
|
||
|
||
PART 2 — the TASK-SCOPED GIVENS: everything the WHOLE task runs on,
|
||
even when this segment did not establish it and even when it feels
|
||
too elementary to state. Bindings (what each name/symbol/label
|
||
stands for), fixed values the task supplies rather than derives,
|
||
conventions adopted (indexing base, units, timezone, defaults), and
|
||
this segment's own result WITH its value ("x = 7", not "the value
|
||
was computed").
|
||
|
||
Write PART 2 as data, one item per line, values as given:
|
||
retry budget = 5; timeout 30s; index starts at 0
|
||
step 3 input: n=64, tolerance 1e-6
|
||
slot B: type=private, cost 688.7-721.3 per unit
|
||
A line should be almost entirely values. Keep the trace's notation —
|
||
symbols, units, numbers, spellings — and drop its sentences: strip
|
||
the phrases that announce a given rather than being one ("I would
|
||
prefer", "it should be somewhere between … and …"). They repeat once
|
||
per item, so a 15-item list pays for them 15 times.
|
||
Carry them on EVERY fact; never write "as above" or "same as the
|
||
previous session" — retrieval returns a handful of facts by
|
||
similarity and the referent may not be among them.
|
||
|
||
Aim under 1200 characters total. If PART 2 does not fit, fix the
|
||
notation, never the coverage: statement is the retrieval vector AND
|
||
the only field a later segment can read (details is neither embedded
|
||
nor injected), so anything cut from it is gone. Compress the wording
|
||
around a given; never the given.
|
||
|
||
The test: if the next segment cannot see the original material, can
|
||
it continue from this record alone? Only a yes counts. A missing
|
||
binding does not fail loudly — the name still looks meaningful, so
|
||
it gets read with its general-usage meaning and the answer that
|
||
follows is fluent and about a different question.
|
||
|
||
LANGUAGE — same language as the session trace: statement is the
|
||
retrieval vector and queries arrive in the user's working language,
|
||
so another language pays a cross-lingual penalty on every lookup.
|
||
|
||
EVIDENCE FIDELITY — record only actions actually executed and
|
||
evidence explicitly present in the trace. Preserve material
|
||
commands, flags, arguments, errors, and numbers verbatim.
|
||
NEVER complete a command with customary flags, and never turn
|
||
advice or plans into actions:
|
||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||
(the trace did not contain `-Force`; do not invent it)
|
||
Scope every negative finding to what was actually searched, and
|
||
enumerate EVERY explicit coverage limit that could invalidate it,
|
||
especially permission failures and depth limits: keep the roots,
|
||
filters, and depth that were in effect ("not found under C:\\ and
|
||
D:\\ at depth 3; some directories were not covered due to Access
|
||
denied"), never widen it to "does not exist". Do not omit one limit
|
||
to make the record prettier.
|
||
Never promote a partial search to global absence.
|
||
A blocked attempt keeps its blocker.
|
||
Preserve the distinction among attempted, observed, verified,
|
||
inferred, and unresolved — express it naturally ("tried … but was
|
||
blocked by Access denied", "suspected …, not yet verified"), not as
|
||
labels. Without tool output or explicit confirmation, NEVER claim a
|
||
fix succeeded, a cause was established, or a path does not exist.
|
||
|
||
Do NOT record greetings or pure chatter — that is the only case
|
||
getting "fact": null. A segment that merely stated requirements, or
|
||
stalled before doing work, still established something.
|
||
|
||
details — The session's concrete OUTPUT, copied over verbatim. `statement` is
|
||
a summary and a summary necessarily drops the individual values; a
|
||
later lookup almost always wants one specific value, not the gist.
|
||
So whatever addressable result this session produced, reproduce it
|
||
here in its original form and structure:
|
||
a plan / schedule / itinerary → keep every line, every day, every
|
||
slot, and every price exactly as written
|
||
a config or code block → keep the block
|
||
a command's material output → keep the lines that carry the
|
||
findings (sizes, counts, versions, error text)
|
||
a structured result (table, list of records) → keep the rows
|
||
Preserve the original layout — line breaks, `Day 1:` / key: value
|
||
prefixes, units, currency symbols, ids. Do not re-summarize, do not
|
||
reorder, do not round numbers, do not translate.
|
||
|
||
Copy ONLY what is present in the trace. Never fill a gap: if a slot
|
||
was left unfilled, leave it unfilled or say so — an invented value
|
||
is worse than a missing one, because it will be retrieved and used.
|
||
Cap around 4000 characters; if the output is longer, keep the parts
|
||
carrying concrete values and drop decorative prose, noting what was
|
||
dropped.
|
||
|
||
"" when the session produced no such artifact (pure investigation
|
||
with no findings yet, or a stall waiting on the user).
|
||
|
||
This field is NOT part of the retrieval vector — `statement` and
|
||
`subject` do the finding, `details` supplies the value once found.
|
||
So do not write retrieval bait here, and do not duplicate the
|
||
summary; write the raw thing.
|
||
|
||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||
operated on or investigated during this session. Keep investigated
|
||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||
actually accessed/searched, not paths merely suggested or planned;
|
||
[] if none apply.
|
||
═══════════════════════════════════════════════════════════════════
|
||
HOST CONTEXT (given by SDK)
|
||
═══════════════════════════════════════════════════════════════════
|
||
- workspace_id present: {workspace_present}
|
||
- branch present: {branch_present}
|
||
|
||
When no workspace_id is present, the solution cannot rely on unstated
|
||
project-local context — spell out any repo / path / service it needs inside
|
||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
CONVERSATION
|
||
═══════════════════════════════════════════════════════════════════
|
||
{conversation_block}
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
OUTPUT (strict JSON only, no markdown)
|
||
═══════════════════════════════════════════════════════════════════
|
||
{{
|
||
"experiences": [
|
||
{{
|
||
"task": "...",
|
||
"search_keys": ["...", "..."],
|
||
"solution": "...",
|
||
"boundary_envs": "...",
|
||
"confidence": 0.9
|
||
}}
|
||
],
|
||
"fact": {{
|
||
"subject": "...",
|
||
"statement": "...",
|
||
"files": ["..."],
|
||
"details": "..."
|
||
}}
|
||
}}
|
||
|
||
DEFAULT TO EXTRACTING. When the segment did real work, you MUST output at least
|
||
one experience — even if the task looks simple or was a one-shot success, still
|
||
capture the reusable essence of what was done (the method / command / how-to).
|
||
When the session is thin, the single most valuable experience is enough.
|
||
|
||
The ONE exception is the stall case above: if the segment did no real work
|
||
because it was blocked asking the user for missing information, output
|
||
"experiences": [] and write only the fact. Do not manufacture an experience to
|
||
satisfy this instruction — a fabricated one ("how to ask for the departure
|
||
city") is worse than none, because it is later retrieved and followed. Empty is
|
||
allowed ONLY for that reason, never as a shortcut when work was actually done.
|
||
|
||
"fact" is a SINGLE object, never a list — one record per session.
|
||
|
||
ALWAYS write the fact. The bar for the fact is not "was this valuable" but "did
|
||
anything at all happen", and a segment reaching you has already cleared that.
|
||
This includes segments that look like setup rather than work: a single
|
||
user message stating requirements, parameters, or a starting state, with no
|
||
tools run, is exactly the material a later session cannot reconstruct on its own
|
||
— 把需求、参数、起始状态连同情形一起写进 `statement`(紧凑记法,不要照抄对话原句),
|
||
不要推给 `details`(`details` 不进检索向量,读取侧也不再注入它)。Use
|
||
"fact": null ONLY for greetings and pure chatter with no content whatsoever.
|
||
When in doubt, write the fact: an unneeded record costs one row, while a dropped
|
||
one costs every later session that needed it and has no way to know it existed.
|
||
"""
|
||
|
||
|
||
# ================================================================
|
||
# Prompt —— scene="coding"(软件工程场景)
|
||
# ================================================================
|
||
# 与上面 EXTRACT_PROMPT(scene="working") 的关系: 同一个抽取器、同一套占位符
|
||
# ({workspace_present}/{branch_present}/{conversation_block})、同一个输出 schema,
|
||
# 只换规范。两份并存而不是改一份, 是因为两个场景对 statement 的要求不同,
|
||
# 共用一份会让其中一个场景的规范被另一个稀释。
|
||
#
|
||
# 这份独有的三块(working 侧没有):
|
||
# 1. HARD CONSTRAINT 头部 —— 前置覆盖后文任何"想要叙事"的措辞: 语法主语必须是
|
||
# CODE 或 DECISION 而非 "the user"; 不许以 "the session was completed" 收尾;
|
||
# 必须保留具体症状(报错串/失败命令/错误输出), 那是未来检索的命中面。
|
||
# 2. CODE WORK 整节 —— 四件未来 agent 无法从代码反推的东西: 分支名(逐字)、
|
||
# 哪一版赢了(settled on A; interim B dropped)、精确坐标(仓相对路径+符号,
|
||
# 不是贴代码)、verified 还是仅 written。它明确不要求更长, 要求同样预算花在
|
||
# 更高价值的 token 上。
|
||
# 3. statement 的三段式 —— OPEN 症状(用户会怎么报) → THEN 坐标+决策 →
|
||
# CLOSE 证据状态。硬顶 ~400 latin 字符。
|
||
#
|
||
# 这份少掉的(working 侧有、coding 侧刻意不带):
|
||
# · "问用户"排除段 —— 卡在向用户要参数时强制 experiences: []。coding 场景的
|
||
# 轨迹是 agent 自主执行, 不存在这种停顿。
|
||
# · statement PART 2 的 TASK-SCOPED GIVENS 逐行记法 —— coding 侧的给定量是
|
||
# 代码坐标, 已由 CODE WORK 节覆盖。
|
||
# · details 的产物复制规范 —— coding 侧 details 不进检索向量也不注入。
|
||
#
|
||
# 修改 prompt 时应使用代表性 coding traces 做回归,确认坐标、决策与验证状态仍保留。
|
||
|
||
EXTRACT_PROMPT_CODING = """\
|
||
╔═══════════════════════════════════════════════════════════════════════════╗
|
||
║ HARD CONSTRAINT — read this before anything else, it overrides any ║
|
||
║ phrasing later in this prompt that sounds like it wants a narrative. ║
|
||
║ ║
|
||
║ You are NOT writing a report of what a person did. You are writing what a ║
|
||
║ FUTURE AGENT needs in order to act. That agent already has its own task; ║
|
||
║ it does not care what this user wanted. It needs to know: what was decided ║
|
||
║ here, at which code coordinates, what was tried and rejected, and whether ║
|
||
║ it was verified. ║
|
||
║ ║
|
||
║ Concretely, in every `statement` and every `solution`: ║
|
||
║ · The grammatical subject is the CODE or THE DECISION — never "the user" ║
|
||
║ and never "the agent". Do not write "The user attempted to…", ║
|
||
║ "The user wanted…", "The agent then checked…". ║
|
||
║ · Never close with a bookkeeping phrase like "the session was completed", ║
|
||
║ "the error was noted", "no solution was found yet". Close with the ║
|
||
║ decision and its verification status. If nothing was decided, close ║
|
||
║ with what was RULED OUT and the next unexplored lead — that is the ║
|
||
║ reusable part. ║
|
||
║ · Keep the concrete symptom (error string, failing command, wrong ║
|
||
║ output) — that is what a future search will match on. ║
|
||
║ ║
|
||
║ bad: "The user attempted to write a QTable to RST with header_rows and ║
|
||
║ hit a TypeError. The session was completed with the error noted." ║
|
||
║ good: "ascii.rst's RST writer rejects header_rows: `tbl.write(..., \ ║
|
||
║ format='ascii.rst', header_rows=['name','unit'])` raises TypeError ║
|
||
║ because RST.__init__ does not forward it, unlike ascii.fixed_width ║
|
||
║ which accepts it. No fix written — the writer needs the same ║
|
||
║ header_rows plumbing as fixed_width." ║
|
||
╚═══════════════════════════════════════════════════════════════════════════╝
|
||
|
||
You extract durable WORKING memories from a task-execution session segment.
|
||
A "working" session is any hands-on task where the user operates a computer /
|
||
system through tools — this covers BOTH software/code work (writing, debugging,
|
||
refactoring, building, deploying, API/library usage) AND system & operations
|
||
work (running shell/PowerShell commands, disk/storage analysis or cleanup, file
|
||
& folder operations, installing/configuring software, environment/device setup,
|
||
hardware/disk health inspection, backup, scheduling/automation, data processing).
|
||
|
||
You produce TWO layers from the SAME segment:
|
||
|
||
EXPERIENCES (procedural, "how to do it") — reusable method, extracted only
|
||
when it clears the VALUE BAR below. A (task, search_keys, solution,
|
||
boundary_envs) bundle.
|
||
|
||
FACT (episodic, "what was established this time") — EXACTLY ONE record of THIS
|
||
session. It is NOT a narrative of what the user did. It records, in this order:
|
||
the task and its concrete symptom (this is what makes it findable later), the
|
||
code coordinates, WHAT WAS SETTLED ON and what was rejected, and how far it was
|
||
verified. No value bar, but there is never more than one.
|
||
|
||
The two layers answer different questions and are stored separately. A method
|
||
that generalizes goes in EXPERIENCES; the story of this particular session —
|
||
only true for this system / this project / right now — goes in the FACT.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
VALUE BAR (EXPERIENCES ONLY) — DO NOT cross this lightly.
|
||
═══════════════════════════════════════════════════════════════════
|
||
A memory MUST satisfy ALL of:
|
||
(a) Solves a problem likely to recur for the same user
|
||
(b) Solution is non-trivial: combines multiple pieces of info / steps, OR
|
||
carries a decision rationale, OR captures a hard-won workaround, OR is a
|
||
reusable procedure/recipe for an operational task (e.g. how to analyze &
|
||
clean up disk usage, how to batch-organize files, how to inspect hardware
|
||
health, how to set up a scheduled job)
|
||
(c) A future agent / user would prefer looking it up over re-discovering it
|
||
|
||
IMPORTANT — analysis / diagnostic / investigation tasks COUNT:
|
||
A task does NOT have to make a change or "fix" something to be worth remembering.
|
||
Read-only / analytical / troubleshooting work is valuable when its PROCEDURE or
|
||
CONCLUSION is reusable. Explicitly extract:
|
||
- Analysis / diagnostic recipes: the reusable procedure + what it revealed
|
||
(e.g. "how to analyze which folders eat C-drive space" — the scan commands,
|
||
the gotchas hit, and the finding). "It only produced a report, no change"
|
||
is NOT a reason to skip — the reusable METHOD is the memory.
|
||
- Investigation outcomes, INCLUDING negative / not-found results
|
||
(e.g. "searched folder X across C: and D: with rg / fs_search / SQL index —
|
||
not found anywhere"). A recorded dead-end saves the next agent from repeating
|
||
the same fruitless search, so it clears the bar.
|
||
Do NOT reject a memory merely because "the assistant could regenerate the
|
||
commands" or "nothing was actually modified". If the procedure or the conclusion
|
||
would save future effort, keep it.
|
||
|
||
DO NOT extract:
|
||
- Truly trivial single-step ops with no reusable procedure ("rename one file",
|
||
"git status", "run ls once")
|
||
- One-off issues already resolved with no transferable lesson
|
||
- Information that is already in the repo/system (don't duplicate package.json)
|
||
- Per-PR / per-session throwaway tasks unless they encode a reusable pattern
|
||
|
||
Heuristic: if the user (or a future agent doing the same kind of task) would
|
||
want this written down instead of figuring it out again, keep it. A multi-step
|
||
operational procedure (even a system/ops or read-only analysis/search one, not
|
||
just code) usually qualifies. Only drop it if it is a single command any user
|
||
would find via `--help` in 30 seconds AND carries no decision/gotcha worth
|
||
remembering.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
CODE WORK — what a future agent cannot re-derive, so it MUST be captured
|
||
═══════════════════════════════════════════════════════════════════
|
||
When the segment is software work on a repository, the reusable method is only
|
||
half of it. The other half is WHERE the work lives and WHICH VERSION won. Those
|
||
cannot be recovered by reading the code later — the code shows the current state,
|
||
not which branch it sits on, nor what was already rejected.
|
||
|
||
This section does NOT ask for a longer record. It asks you to SPEND THE SAME
|
||
BUDGET on higher-value tokens: drop restated goals, narration of what the
|
||
assistant "then decided to", and pasted code you are only echoing; spend those
|
||
characters on the four items below instead. A record that omits the branch but
|
||
quotes 30 lines of diff is a bad trade.
|
||
|
||
1. BRANCH / WORKING LOCATION — verbatim, in the fact `statement`. If the segment
|
||
mentions or checks out `feat/x` / `fix/y`, that token MUST appear. An agent
|
||
that resumes on the wrong branch produces changes that are silently thrown
|
||
away, so this one token outranks every other detail.
|
||
|
||
2. WHICH VERSION WON — when the user changed their mind, name the winner and mark
|
||
the loser in one clause: "settled on A; the interim B was dropped". Not both in
|
||
full — one clause. Keeping only the latest is wrong: an agent that meets the
|
||
rejected code must recognize it, or it will "fix" things back to the discarded
|
||
variant. Phrases like `scratch that / go back to / actually, let's / no, not that`
|
||
mark this: what follows is of record, what precedes is superseded.
|
||
|
||
3. EXACT COORDINATES, NOT PASTED CODE — repo-relative path
|
||
(`qutebrowser/utils/debug.py`, never bare `debug.py`) plus the function /
|
||
class touched. If a signature changed, give the new signature. If a constant,
|
||
regex or literal was decided, quote THAT verbatim — but quote only it, not the
|
||
surrounding block. One accurate line beats a paragraph of approximation.
|
||
|
||
4. VERIFIED vs MERELY WRITTEN — four words is enough ("tests run and passing" /
|
||
"confirmed by git diff only, tests not run"). "the edit is in" and "the edit is in
|
||
and tested" lead a future agent to do very different things next.
|
||
|
||
Anti-pattern — summarizing the goal and dropping the coordinates:
|
||
bad: "refactored the colour parsing logic to be cleaner."
|
||
good: "on feat/q4, in qutebrowser/config/configtypes.py QtColor._parse_value:
|
||
settled on `mult = 360.0 if kind == 'h' else 255.0`; the interim version that
|
||
used 255.0 for everything was dropped. Tests not run."
|
||
(the good one is SHORTER than most bad ones — it just spends every character
|
||
on something that cannot be re-derived)
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
EXPERIENCE FIELDS
|
||
═══════════════════════════════════════════════════════════════════
|
||
|
||
task — Query-shaped name; phrase as how a user would later ASK.
|
||
"publish a release to the xx platform", not "publish_release_internal"
|
||
Self-contained (no "this", "the file", "we").
|
||
|
||
search_keys — 0 to 5 alternative phrasings capturing important aspects of
|
||
solution that `task` alone wouldn't match. Phrase as questions
|
||
or topics future users would ASK ABOUT. Distinct from task.
|
||
Omit if task already covers everything.
|
||
|
||
solution — Complete, self-contained content the user actually needs:
|
||
commands, credential locations (NOT credential VALUES),
|
||
file paths, ordered steps, gotchas, decision rationale.
|
||
Aggregate multi-turn end-to-end work into ONE solution if it
|
||
forms a coherent task; split into multiple memories only when
|
||
the user did unrelated tasks.
|
||
|
||
boundary_envs — Concrete pinning that solution depends on, multi-line "key: value":
|
||
runtime / SDK / library + version
|
||
API endpoint or method signature
|
||
external service / middleware version
|
||
env var NAME (not value)
|
||
config file path
|
||
host / cluster / region identifier
|
||
If solution is environment-agnostic, leave as "" (empty).
|
||
Purpose: when the underlying SDK/middleware upgrades, future
|
||
consumers can inspect this to decide if solution still applies.
|
||
|
||
confidence — 0.0 to 1.0
|
||
1.0 user explicitly stated this as a rule / instruction
|
||
0.7 inferred with good evidence from tool actions
|
||
0.5 weakly inferred; one observation; uncertain
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
FACT FIELD — EXACTLY ONE per segment, no value bar
|
||
═══════════════════════════════════════════════════════════════════
|
||
|
||
You write ONE fact for the whole segment — a recoverable state record for this
|
||
session, not a polished retrospective and never a list of separate findings. A
|
||
future agent must be able to resume from it without mistaking a suggestion,
|
||
inference, or partial search for something verified.
|
||
|
||
statement — A compact summary of THIS session. It has two jobs at once, and it
|
||
must do BOTH: it is the retrieval vector (a future agent finds it by
|
||
searching with a fresh issue report, phrased the way a user phrases a
|
||
problem), and it is the payload that agent then acts on. So:
|
||
|
||
OPEN with the task and the symptom, in the words a user would use.
|
||
This is what makes it findable — keep the surface form close to how
|
||
the problem would be reported. Name the specific goal and object
|
||
("reclaim disk space under D:\\WXWork", "adjust the hue multiplier
|
||
in QtColor._parse_value on feat/q4", "locate the new_file_search
|
||
folder"), never a generic "fix a bug" / "look for a file". Include
|
||
the concrete symptom: the error string, the failing command, the
|
||
wrong output.
|
||
|
||
THEN give the coordinates and the decision.
|
||
repo-relative path, symbol, and branch for code work. State what was
|
||
SETTLED ON, and in the same clause mark what was dropped
|
||
("settled on X; the interim Y was dropped"). This is the part that
|
||
cannot be re-derived by reading the code — the code only shows the
|
||
winner, never that Y was tried and rejected. An agent that meets the
|
||
rejected variant later must be able to recognize it, or it will
|
||
"fix" the code back to the discarded version.
|
||
|
||
CLOSE with the evidence status.
|
||
Exactly one of: completed, partially completed, failed, blocked, or
|
||
still awaiting verification — plus how it was checked ("tests run and
|
||
passing" / "confirmed by git diff only, tests not run"). Include
|
||
negative results and dead ends without overstating them.
|
||
|
||
⚠️ Never stop at the narrative. "The user tried X and hit error Y; no
|
||
solution found yet" is a wasted record: it retrieves fine but gives a
|
||
future agent nothing to act on. If the session genuinely reached no
|
||
decision, say what was ruled out and what the next unexplored lead is —
|
||
that is the reusable part.
|
||
|
||
The subject of the sentences should be the code and the decision, not
|
||
"the user". 2 to 4 sentences, flowing prose, not labelled sections.
|
||
Hard ceiling ~400 latin characters (~200 CJK characters).
|
||
|
||
EVIDENCE FIDELITY — mandatory:
|
||
- Record only actions actually executed and evidence explicitly
|
||
present in the trace. Preserve material commands, flags,
|
||
arguments, errors, and numbers verbatim. NEVER complete a
|
||
command with customary flags or turn advice/plans into actions.
|
||
- Preserve the distinction among attempted, observed, verified,
|
||
inferred, and unresolved. Do not add those labels to the prose;
|
||
express their meaning naturally, e.g. "tried … but was blocked by
|
||
Access denied" or "suspect … , not yet verified".
|
||
- Without tool output or explicit confirmation, NEVER claim a fix
|
||
succeeded, a cause was established, a file/folder does not
|
||
exist, or tests passed.
|
||
- Scope every negative finding to what was actually searched and
|
||
enumerate EVERY explicit coverage limit that could invalidate
|
||
it, especially permission failures and maximum search depth:
|
||
"not found within the searched parts of C:\\ and D:\\; some
|
||
directories were skipped on Access denied, and the search was also
|
||
depth-limited". Do not omit one limit to make the
|
||
summary prettier. Never promote a partial search to global
|
||
absence.
|
||
- State causality as certain only when the trace directly verifies
|
||
it. Otherwise retain the hedge ("suspected" / "inferred" / "not yet
|
||
verified"); do not rewrite an
|
||
incorrect test assumption as the system's root cause.
|
||
|
||
CRITICAL — this text IS the retrieval vector, and it is the only
|
||
thing a future query gets matched against. A generic summary is
|
||
worthless, because EVERY session looks like a generic summary: it
|
||
would match every query and answer none of them. So you MUST name
|
||
the concrete anchors that make THIS session findable — real file
|
||
paths, function / symbol names, branch names, commands, error
|
||
strings, versions, config keys and values, host / service names.
|
||
If someone later searches for any one of those specifics, this
|
||
summary has to be the thing that comes back.
|
||
good: "fixing a failing `npm run build` in CI. Read
|
||
.github/workflows/ci.yml and package.json; traced it to webpack
|
||
5.74 hitting ERR_OSSL_EVP_UNSUPPORTED under node 18. Bumping the
|
||
workflow's node to 20 made the build pass; node 18 still
|
||
reproduces locally."
|
||
good: "on branch feat/q0, in qutebrowser/utils/debug.py signal_name:
|
||
PyQt<5.11 only exposes the name via repr(sig), and repr comes in
|
||
both a bracketed and a parenthesised form. Settled on two patterns
|
||
tried in order with fullmatch, breaking on the first hit; the
|
||
single-regex version was dropped. Confirmed by git diff, tests not
|
||
run."
|
||
bad: "user wanted a build bug fixed, the agent looked at some config
|
||
files, changed some code, and fixed it."
|
||
(no path, no command, no error string, no version — nothing
|
||
to retrieve on; every session reads exactly like this)
|
||
bad: "fixed the signal-name parsing compatibility issue on a feature
|
||
branch."
|
||
(which branch? which file? which function? which of the two
|
||
competing versions won? nothing here survives being
|
||
retrieved a week later)
|
||
Fidelity examples (bad → good):
|
||
"ran Get-ChildItem -Force" → "ran Get-ChildItem"
|
||
(the trace did not contain `-Force`; do not invent it)
|
||
"confirmed new_file_search does not exist" →
|
||
"new_file_search was not found within the searched parts of C:\\ and
|
||
D:\\; some directories were skipped on Access denied or by the depth
|
||
limit, so the investigation is only partly done"
|
||
"identical payloads cause a spurious lock error" →
|
||
"the verification script assumed the last writer is always a fixed
|
||
rank; that assumption does not hold, so whether there is a lock bug
|
||
is still unverified"
|
||
|
||
Do NOT record greetings or pure chatter. If the segment established
|
||
nothing at all, output "fact": null.
|
||
|
||
files — 0..N absolute or repo-relative FILE OR DIRECTORY paths actually
|
||
operated on or investigated during this session. Keep investigated
|
||
roots such as `C:\\` and `D:\\`. Only include paths that were
|
||
actually accessed/searched, not paths merely suggested or planned;
|
||
[] if none apply.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
HOST CONTEXT (given by SDK)
|
||
═══════════════════════════════════════════════════════════════════
|
||
- workspace_id present: {workspace_present}
|
||
- branch present: {branch_present}
|
||
|
||
When no workspace_id is present, the solution cannot rely on unstated
|
||
project-local context — spell out any repo / path / service it needs inside
|
||
`solution` or `boundary_envs` so the memory stays usable on its own.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
CONVERSATION
|
||
═══════════════════════════════════════════════════════════════════
|
||
{conversation_block}
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
OUTPUT (strict JSON only, no markdown)
|
||
═══════════════════════════════════════════════════════════════════
|
||
{{
|
||
"experiences": [
|
||
{{
|
||
"task": "...",
|
||
"search_keys": ["...", "..."],
|
||
"solution": "...",
|
||
"boundary_envs": "...",
|
||
"confidence": 0.9
|
||
}}
|
||
],
|
||
"fact": {{
|
||
"statement": "...",
|
||
"files": ["..."]
|
||
}}
|
||
}}
|
||
|
||
MANDATORY: You MUST output at least ONE experience. Do NOT return an empty list.
|
||
Even if the task looks simple or is a one-shot success, still capture the
|
||
reusable essence of what was done (the method / command / how-to). Only the
|
||
single most valuable experience is required when the session is thin — but never
|
||
output an empty "experiences" array.
|
||
|
||
"fact" is a SINGLE object, never a list — one record per session. Use
|
||
"fact": null only when the segment is pure chatter that established nothing.
|
||
"""
|
||
|
||
|
||
# ================================================================
|
||
# CodingMemoryExtractor
|
||
# ================================================================
|
||
|
||
class CodingMemoryExtractor:
|
||
"""
|
||
LLM 驱动的双层抽取器。
|
||
|
||
输入:本次完整 New Messages + 历史最近 K 个 user-turn;两部分的 LLM 视图均限长
|
||
输出:(List[CodingMemoryDraft], List[WorkingFactDraft])
|
||
|
||
事实层一段轨迹只产一条,所以返回的 fact 列表长度恒为 0 或 1;用列表而不用
|
||
Optional 是为了让 writer / reconciler 的签名不必跟着改。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm_provider: LLMProvider,
|
||
max_tokens: int = 6000,
|
||
temperature: Optional[float] = None,
|
||
cache: Any = None,
|
||
scene: str = "working",
|
||
verbatim_fallback_enabled: bool = False,
|
||
):
|
||
# scene 决定用哪份抽取规范。直接构造 extractor 时默认 working;client 的
|
||
# scene=None 自动路由会先经三分类 judge,再构造对应 variant。
|
||
# "working" → EXTRACT_PROMPT (办公 / 通用生产力)
|
||
# "coding" → EXTRACT_PROMPT_CODING (软件工程)
|
||
# 非法值不抛错、退回 working:抽取是一次性的,为一个拼错的参数让整段
|
||
# 写入失败不值得。
|
||
self.scene = "coding" if str(scene or "").strip().lower() == "coding" else "working"
|
||
self._prompt_tmpl = (
|
||
EXTRACT_PROMPT_CODING if self.scene == "coding" else EXTRACT_PROMPT
|
||
)
|
||
self.llm_provider = llm_provider
|
||
self.max_tokens = max_tokens
|
||
# 默认关闭:原文兜底可能把 assistant/tool 轨迹直接写进 statement,随后被
|
||
# 搜索消费者当作记忆正文展示。需要旧行为时由 CodingConfig 显式开启。
|
||
self.verbatim_fallback_enabled = bool(verbatim_fallback_enabled)
|
||
# cache 非空时落 CODING_EXTRACT pipeline log(best-effort)
|
||
self.cache = cache
|
||
# None → 使用 LLMConfig.temperature(避免硬编码触发 kimi/deepseek 的温度硬约束)
|
||
if temperature is None:
|
||
# None 一路传到 provider = 不带该参数;此前兜 0.1 会绕过用户配置,
|
||
# 在只接受特定 temperature 的网关上(Opus 5 只收 1 或不传)直接 400
|
||
temperature = _resolve_configured_temperature(llm_provider)
|
||
self.temperature = temperature
|
||
|
||
@staticmethod
|
||
def _language_text(messages: List[ChatMessage]) -> str:
|
||
"""Use new user text for routing; tool output must not dominate it."""
|
||
user_parts = [
|
||
str(message.content or "").strip()
|
||
for message in messages
|
||
if message.role == "user" and str(message.content or "").strip()
|
||
]
|
||
if user_parts:
|
||
return "\n".join(user_parts)
|
||
|
||
non_tool_parts = [
|
||
str(message.content or "").strip()
|
||
for message in messages
|
||
if message.role != "tool" and str(message.content or "").strip()
|
||
]
|
||
if non_tool_parts:
|
||
return "\n".join(non_tool_parts)
|
||
return "\n".join(
|
||
str(message.content or "").strip()
|
||
for message in messages
|
||
if str(message.content or "").strip()
|
||
)
|
||
|
||
def _select_prompt_template(self, messages: List[ChatMessage]) -> Tuple[str, str]:
|
||
"""Return (template, language) for this write's new messages."""
|
||
if not is_chinese(self._language_text(messages)):
|
||
return self._prompt_tmpl, "en"
|
||
|
||
from .prompts_zh import EXTRACT_PROMPT_CODING_ZH, EXTRACT_PROMPT_ZH
|
||
|
||
prompt = EXTRACT_PROMPT_CODING_ZH if self.scene == "coding" else EXTRACT_PROMPT_ZH
|
||
return prompt, "zh"
|
||
|
||
async def extract(
|
||
self,
|
||
messages: List[ChatMessage],
|
||
*,
|
||
history_messages: Optional[List[ChatMessage]] = None,
|
||
tool_result_max_bytes: int = 2048,
|
||
user_id: str,
|
||
agent_id: str = "default_agent",
|
||
workspace_id: Optional[str] = None,
|
||
branch: Optional[str] = None,
|
||
session_id: Optional[str] = None,
|
||
request_id: str = "",
|
||
) -> Tuple[List[CodingMemoryDraft], List[WorkingFactDraft]]:
|
||
"""
|
||
Args:
|
||
messages: 本次 write 的完整新消息;只做单条限长,不裁 turn
|
||
history_messages: 之前 write 的历史消息;仅取最近 5 个 user-turn 作消解上下文
|
||
user_id / agent_id / workspace_id / branch / session_id: SDK 注入
|
||
|
||
Returns:
|
||
(经验草稿, 事实草稿)。两层都可能为空。只有显式启用
|
||
verbatim_fallback_enabled 时,模型没产出 fact 才调用 _verbatim_fact。
|
||
"""
|
||
if not messages:
|
||
return [], []
|
||
|
||
# New Messages 是本次 write 的抽取主体:无论传入 1 个还是 10 个 user-turn,
|
||
# 都必须完整进入 prompt;这里只限制每条 tool / assistant 的体积。
|
||
new_messages = prepare_extractor_messages(
|
||
messages, tool_max_bytes=tool_result_max_bytes,
|
||
)
|
||
|
||
# Last 5 User Turns 是之前 write 的历史,只用于指代消解和上下文。绝不能拿
|
||
# last-K 去裁本次输入,否则 write(10 turns) 会静默丢掉前 5 个 turn。
|
||
history_window = keep_last_k_user_turns(
|
||
prepare_extractor_messages(
|
||
history_messages or [], tool_max_bytes=tool_result_max_bytes,
|
||
),
|
||
k=5,
|
||
)
|
||
|
||
history_block = self._format_conversation(history_window).strip()
|
||
new_block = self._format_conversation(new_messages).strip()
|
||
prompt_tmpl, prompt_language = self._select_prompt_template(new_messages)
|
||
if prompt_language == "zh":
|
||
conversation_block = (
|
||
"最近 5 个用户轮次(仅作上下文,不要重复抽取):\n"
|
||
+ (history_block or "(无)")
|
||
+ "\n\n本次新增消息(只从这次完整写入中抽取):\n"
|
||
+ (new_block or "(无)")
|
||
)
|
||
else:
|
||
conversation_block = (
|
||
"LAST 5 USER TURNS (CONTEXT ONLY; do not extract them again):\n"
|
||
+ (history_block or "(none)")
|
||
+ "\n\nNEW MESSAGES (extract only from this complete write):\n"
|
||
+ (new_block or "(none)")
|
||
)
|
||
|
||
prompt = prompt_tmpl.format(
|
||
workspace_present=str(bool(workspace_id)).lower(),
|
||
branch_present=str(bool(branch)).lower(),
|
||
conversation_block=conversation_block,
|
||
)
|
||
|
||
_t0 = time.perf_counter()
|
||
try:
|
||
resp = await self.llm_provider.complete(
|
||
prompt=prompt,
|
||
max_tokens=self.max_tokens,
|
||
audit_stage="coding_extract",
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[coding-extract] LLM call failed: {e}")
|
||
# 经验层不兜底。事实层原文兜底也默认关闭,避免 assistant/tool 轨迹被
|
||
# 当作 statement 直接展示;仅在配置显式开启时保留旧行为。
|
||
verbatim = (
|
||
self._verbatim_fact(messages)
|
||
if self.verbatim_fallback_enabled else None
|
||
)
|
||
fact_source = (
|
||
"verbatim_fallback" if verbatim
|
||
else "verbatim_fallback_disabled"
|
||
if not self.verbatim_fallback_enabled
|
||
else "none"
|
||
)
|
||
await _store_coding_log(
|
||
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||
session_id=session_id or "", step="CODING_EXTRACT", prompt=prompt,
|
||
response=f"[LLM error] {type(e).__name__}: {str(e)[:300]}",
|
||
parsed=json.dumps(
|
||
{
|
||
"drafts": 0,
|
||
"llm_error": True,
|
||
"facts": [verbatim.to_dict()] if verbatim else [],
|
||
"fact_count": 1 if verbatim else 0,
|
||
"fact_source": fact_source,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
elapsed_ms=round((time.perf_counter() - _t0) * 1000, 2),
|
||
)
|
||
return [], ([verbatim] if verbatim else [])
|
||
_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
|
||
|
||
raw_experiences, raw_facts = self._parse_output(resp.content)
|
||
|
||
# 经验层的 files 走规则抽取(整段轨迹碰过的文件);事实层的 files 由 LLM
|
||
# 给,它挑的是"这次真正动过/查过的",比规则抽取的全集更贴会话小结
|
||
files = extract_files(messages)
|
||
|
||
drafts: List[CodingMemoryDraft] = []
|
||
for raw in raw_experiences:
|
||
d = self._normalize_draft(
|
||
raw,
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
workspace_id=workspace_id,
|
||
branch=branch,
|
||
session_id=session_id,
|
||
files=files,
|
||
)
|
||
if d is not None:
|
||
drafts.append(d)
|
||
|
||
fact_drafts: List[WorkingFactDraft] = []
|
||
for raw in raw_facts:
|
||
f = self._normalize_fact_draft(raw)
|
||
if f is not None:
|
||
fact_drafts.append(f)
|
||
|
||
# details 不信 LLM 的抄写,改从未压缩的 messages 直接取。
|
||
#
|
||
# 抽取提示词里的对话是窗口化、截断过的(见 preproc),长 assistant 文本的
|
||
# 中段在建 prompt 时已经不在了。让模型"逐字抄写"一段它看不到的原文,只能
|
||
# 抄出带省略标记的头尾。
|
||
#
|
||
# details 的意义是留住被摘要丢掉的可寻址产物(计划表、配置块等)。它不进
|
||
# 检索向量(见 store._upsert_fact_vec),长度不影响召回,没有理由再走一遍
|
||
# 有损通道——直接取原文,只受 details 自己的上限约束。
|
||
#
|
||
# 注意:绕过一个有损环节不等于修好它。输入侧的截断阈值同样会影响 statement,
|
||
# 见 preproc.DEFAULT_ASSISTANT_HEAD_CHARS。
|
||
#
|
||
# LLM 给的 details 仍然留着当兜底:messages 为空这种情况下它是唯一来源。
|
||
verbatim_details = self._verbatim_details(messages)
|
||
if verbatim_details:
|
||
fact_drafts = [
|
||
WorkingFactDraft(
|
||
statement=f.statement,
|
||
files=f.files,
|
||
subject=f.subject,
|
||
details=verbatim_details,
|
||
)
|
||
for f in fact_drafts
|
||
]
|
||
|
||
fact_source = "llm"
|
||
if not fact_drafts:
|
||
if self.verbatim_fallback_enabled:
|
||
verbatim = self._verbatim_fact(messages)
|
||
if verbatim is not None:
|
||
fact_drafts.append(verbatim)
|
||
fact_source = "verbatim_fallback"
|
||
else:
|
||
fact_source = "none"
|
||
else:
|
||
fact_source = "verbatim_fallback_disabled"
|
||
|
||
logger.info(
|
||
f"[coding-extract] produced {len(drafts)} experience draft(s), "
|
||
f"{'1 session fact' if fact_drafts else 'no session fact'}"
|
||
f"{' (verbatim fallback: LLM returned none)' if fact_source == 'verbatim_fallback' else ''}"
|
||
f"{' (verbatim fallback disabled)' if fact_source == 'verbatim_fallback_disabled' else ''}"
|
||
f" (workspace={workspace_id!r}, branch={branch!r})"
|
||
)
|
||
# CODING_EXTRACT:无论产出几条 draft(含 0 条)都落一条,便于排查"没抽到"。
|
||
# 双层架构下 parsed 同时记录经验草稿与事实草稿,方便追查两层各自产出。
|
||
await _store_coding_log(
|
||
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
|
||
session_id=session_id or "", step="CODING_EXTRACT", prompt=prompt,
|
||
response=resp.content or "",
|
||
parsed=json.dumps(
|
||
{
|
||
"experiences": [d.to_dict() for d in drafts],
|
||
"experience_count": len(drafts),
|
||
"facts": [f.to_dict() for f in fact_drafts],
|
||
"fact_count": len(fact_drafts),
|
||
"fact_source": fact_source,
|
||
},
|
||
ensure_ascii=False, default=str,
|
||
),
|
||
elapsed_ms=_elapsed, prompt_tokens=resp.prompt_tokens,
|
||
completion_tokens=resp.completion_tokens, total_tokens=resp.tokens_used,
|
||
)
|
||
return drafts, fact_drafts
|
||
|
||
# ------------------------------------------------------------
|
||
# Helpers
|
||
# ------------------------------------------------------------
|
||
|
||
@classmethod
|
||
def _verbatim_details(cls, messages: List[ChatMessage]) -> str:
|
||
"""整段轨迹的原文,作为 details 的权威来源。
|
||
|
||
取的是未经 keep_last_k_user_turns 压缩的 messages:details 存在的理由就是
|
||
留住摘要会丢掉的可寻址产物(第 N 天第 M 餐这种格子),而压缩正是丢它的那一步。
|
||
它不进检索向量(store._upsert_fact_vec 只 embed subject + statement),所以
|
||
长度只影响存储,不影响召回质量,唯一的上限是 _MAX_DETAILS_CHARS。
|
||
|
||
从尾部截断:头部是结构化产物的开头(Day 1、配置首行),信息密度最高。
|
||
"""
|
||
if not messages:
|
||
return ""
|
||
body = cls._format_conversation(messages).strip()
|
||
if not body:
|
||
return ""
|
||
if len(body) <= _MAX_DETAILS_CHARS:
|
||
return body
|
||
return body[:_MAX_DETAILS_CHARS] + "\n…(truncated)"
|
||
|
||
@classmethod
|
||
def _verbatim_fact(
|
||
cls, messages: List[ChatMessage]
|
||
) -> Optional[WorkingFactDraft]:
|
||
"""LLM 没给 fact 时的兜底:把轨迹原文存成一条事实。
|
||
|
||
只在真的一个字都没有时返回 None(空消息、全空内容)。这是最后一道闸门,
|
||
判据必须是"有没有内容",不能是"内容够不够好"——够不够好是上面那次 LLM
|
||
调用的判断,它已经判错过了,兜底再复读一遍同样的判断就没有意义。
|
||
|
||
statement 用于检索,所以取用户说的话(用户的话就是未来查询的措辞,
|
||
assistant 的复述和工具输出不是);details 放整段原文,供命中后读取。
|
||
subject 留空——主体只能靠语义判断,规则抽不出来,空串会让 reconciler 退回
|
||
改动前的纯语义判覆盖,而不是拿一个猜错的锚点去卡。
|
||
"""
|
||
if not messages:
|
||
return None
|
||
|
||
user_texts: List[str] = []
|
||
for m in messages:
|
||
if m.role == "user" and not m.is_tool_message():
|
||
text = (m.content or "").strip()
|
||
if text:
|
||
user_texts.append(text)
|
||
|
||
details = cls._verbatim_details(messages)
|
||
if not details:
|
||
return None
|
||
|
||
# 用户原话优先,一句都没有(纯工具回放)就退回整段轨迹的开头
|
||
head = "\n".join(user_texts).strip() or details
|
||
statement = head[:_VERBATIM_STATEMENT_CHARS]
|
||
if len(head) > _VERBATIM_STATEMENT_CHARS:
|
||
statement += "…"
|
||
|
||
return WorkingFactDraft(statement=statement, files=[], subject="", details=details)
|
||
|
||
@staticmethod
|
||
def _format_conversation(messages: List[ChatMessage]) -> str:
|
||
"""渲染整段对话给 LLM 读。"""
|
||
rows: List[str] = []
|
||
for i, m in enumerate(messages):
|
||
if m.role == "user" and not m.is_tool_message():
|
||
rows.append(f"[{i}] user: {m.content}")
|
||
elif m.role == "assistant":
|
||
line = f"[{i}] assistant: {m.content}"
|
||
if m.tool_calls:
|
||
tc_summary = "; ".join(
|
||
f"{tc.name}({_compact_args(tc.arguments)})"
|
||
for tc in m.tool_calls
|
||
)
|
||
line += f" [tools: {tc_summary}]"
|
||
rows.append(line)
|
||
elif m.is_tool_message():
|
||
tn = m.tool_name or "?"
|
||
rows.append(f"[{i}] tool({tn}): {m.content}")
|
||
elif m.role == "system":
|
||
rows.append(f"[{i}] system: {m.content}")
|
||
else:
|
||
rows.append(f"[{i}] {m.role}: {m.content}")
|
||
return "\n".join(rows)
|
||
|
||
@classmethod
|
||
def _parse_output(
|
||
cls, text: str
|
||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
"""宽松解析 LLM 输出 JSON,返回 (experiences, facts) 两个原始列表。"""
|
||
if not text:
|
||
return [], []
|
||
s = text.strip()
|
||
# 去 markdown fence
|
||
if s.startswith("```"):
|
||
lines = s.split("\n")
|
||
body, in_fence = [], False
|
||
for line in lines:
|
||
if line.startswith("```"):
|
||
in_fence = not in_fence
|
||
continue
|
||
body.append(line)
|
||
s = "\n".join(body).strip() or s
|
||
|
||
obj = None
|
||
# 尝试纯 JSON
|
||
try:
|
||
obj = json.loads(s)
|
||
except Exception:
|
||
# 找第一个 {...} 块
|
||
start = s.find("{")
|
||
end = s.rfind("}")
|
||
if start != -1 and end > start:
|
||
try:
|
||
obj = json.loads(s[start: end + 1])
|
||
except Exception:
|
||
obj = None
|
||
|
||
if not isinstance(obj, dict):
|
||
logger.warning(f"[coding-extract] failed to parse output: {text!r}")
|
||
return [], []
|
||
|
||
# "memories" 是双层化之前的 key,老 prompt 缓存 / 旧模型可能还这么回
|
||
experiences = cls._as_dict_list(obj.get("experiences"))
|
||
if not experiences:
|
||
experiences = cls._as_dict_list(obj.get("memories"))
|
||
return experiences, cls._as_fact_list(obj)
|
||
|
||
@classmethod
|
||
def _as_fact_list(cls, obj: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||
"""取出这一段的唯一事实,返回长度 0 或 1 的列表。
|
||
|
||
现契约是单个 "fact" 对象。"facts" 数组是一段轨迹多条事实那版的形状,老
|
||
模型和缓存住的老 prompt 还会这么回,所以继续认,但只留第一条——多出来的
|
||
本来就不该存在,丢掉比让它们各自成为一条会话记录更接近本意。
|
||
"""
|
||
fact = obj.get("fact")
|
||
if isinstance(fact, dict):
|
||
return [fact]
|
||
|
||
legacy = cls._as_dict_list(obj.get("facts"))
|
||
if len(legacy) > 1:
|
||
logger.warning(
|
||
f"[coding-extract] legacy 'facts' array with {len(legacy)} entries; "
|
||
f"keeping the first, dropping {len(legacy) - 1}"
|
||
)
|
||
return legacy[:1]
|
||
|
||
@staticmethod
|
||
def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
return [v for v in value if isinstance(v, dict)]
|
||
|
||
@staticmethod
|
||
def _normalize_draft(
|
||
raw: Dict[str, Any],
|
||
*,
|
||
user_id: str,
|
||
agent_id: str,
|
||
workspace_id: Optional[str],
|
||
branch: Optional[str],
|
||
session_id: Optional[str],
|
||
files: List[str],
|
||
) -> Optional[CodingMemoryDraft]:
|
||
"""
|
||
把 LLM 输出的单条 memory 字典规范化为 CodingMemoryDraft。
|
||
|
||
返回 None 表示该条无效(缺 task 或 solution)。
|
||
"""
|
||
task = (raw.get("task") or "").strip()
|
||
solution = (raw.get("solution") or "").strip()
|
||
if not task or not solution:
|
||
logger.debug(f"[coding-extract] drop draft missing task/solution: {raw}")
|
||
return None
|
||
|
||
# search_keys 规范化
|
||
sk_raw = raw.get("search_keys") or []
|
||
if not isinstance(sk_raw, list):
|
||
sk_raw = []
|
||
search_keys = [str(s).strip() for s in sk_raw if str(s).strip()]
|
||
# 去重 + 与 task 同名的 key 去掉
|
||
seen = set()
|
||
deduped = []
|
||
for k in search_keys:
|
||
if k == task:
|
||
continue
|
||
if k in seen:
|
||
continue
|
||
seen.add(k)
|
||
deduped.append(k)
|
||
search_keys = deduped[:5] # 软上限 5
|
||
|
||
# confidence 规范化
|
||
try:
|
||
confidence = float(raw.get("confidence", 0.7))
|
||
except Exception:
|
||
confidence = 0.7
|
||
confidence = max(0.0, min(1.0, confidence))
|
||
|
||
boundary_envs = (raw.get("boundary_envs") or "").strip()
|
||
|
||
return CodingMemoryDraft(
|
||
task=task,
|
||
search_keys=search_keys,
|
||
solution=solution,
|
||
boundary_envs=boundary_envs,
|
||
confidence=confidence,
|
||
user_id=user_id,
|
||
agent_id=agent_id,
|
||
workspace_id=workspace_id,
|
||
branch=branch,
|
||
session_id=session_id,
|
||
files=list(files),
|
||
extraction_source="auto_extract",
|
||
)
|
||
|
||
@staticmethod
|
||
def _normalize_fact_draft(raw: Dict[str, Any]) -> Optional[WorkingFactDraft]:
|
||
"""把 LLM 输出的单条 fact 规范化。返回 None 表示 statement 为空。"""
|
||
statement = (raw.get("statement") or "").strip()
|
||
if not statement:
|
||
logger.debug(f"[coding-extract] drop fact missing statement: {raw}")
|
||
return None
|
||
|
||
files_raw = raw.get("files") or []
|
||
if not isinstance(files_raw, list):
|
||
files_raw = []
|
||
seen, files = set(), []
|
||
for f in files_raw:
|
||
f = str(f).strip()
|
||
if f and f not in seen:
|
||
seen.add(f)
|
||
files.append(f)
|
||
|
||
subject = " ".join(str(raw.get("subject") or "").split())
|
||
# subject 是硬键而不是散文,一句话说明整段跑偏了,宁可当"没定出主体"处理
|
||
# (退回纯语义判覆盖)也不要拿一句话去比对相等。
|
||
if len(subject) > _MAX_SUBJECT_CHARS:
|
||
logger.debug(
|
||
f"[coding-extract] subject too long, treated as absent: {subject!r}"
|
||
)
|
||
subject = ""
|
||
|
||
details = str(raw.get("details") or "").strip()
|
||
if len(details) > _MAX_DETAILS_CHARS:
|
||
# 从尾部截,头部是结构化产物的开头(Day 1、配置首行),信息密度最高
|
||
details = details[:_MAX_DETAILS_CHARS] + "\n…(truncated)"
|
||
|
||
return WorkingFactDraft(
|
||
statement=statement, files=files, subject=subject, details=details
|
||
)
|
||
|
||
|
||
# ================================================================
|
||
# Helpers
|
||
# ================================================================
|
||
|
||
def _compact_args(args: Dict[str, Any], max_chars: int = 600) -> str:
|
||
"""单行展示 tool args,并为关键命令参数保留足够证据窗口。
|
||
|
||
仍设上限,避免整段脚本/大 payload 撑爆 prompt;但 80 字符会在常见 PowerShell
|
||
命令的 ``-Depth`` / ``-ErrorAction`` 等参数前截断,与 fact 的证据忠实度契约冲突。
|
||
"""
|
||
if not args:
|
||
return ""
|
||
try:
|
||
s = json.dumps(args, ensure_ascii=False, separators=(",", ":"))
|
||
except Exception:
|
||
s = str(args)
|
||
if len(s) > max_chars:
|
||
s = s[: max_chars - 3] + "..."
|
||
return s
|