Files
Hy-Memory/hy_memory/coding/extractor.py
T
alvinfeiandClaude Opus 5.5 dc762df78b feat(sdk): switch to turn the pipeline log off, keep conversation text out of INFO logs (2.0.8)
MEMORY_PIPELINE_LOG_ENABLED (default true) turns off the pipeline JSONL
log. With the DB trace also off, store_pipeline_log becomes a no-op that
does no timing, redaction, background work or database write. The init line
states the real on/off state.

Lines that carried conversation or memory text at INFO/WARNING now log ids,
counts and lengths; the text goes to DEBUG: [add] input, search queries and
rewrites, coding tasks and fact statements/subjects, reconcile op detail,
extracted info, embedding inputs and unparsable model output.

Bump hy-memory to 2.0.8 (not yet published).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 15:03:21 +08:00

1326 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 ({len(text or '')} chars)"
)
logger.debug(f"[coding-extract] unparsable 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