Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ export const DEFAULT_LANGEXTRACT_MODEL = "deepseek/deepseek-v4-flash";
export const DEFAULT_LANGEXTRACT_PROVIDER = "openai";
export const DEFAULT_LANGEXTRACT_BASE_URL = "https://api.novita.ai/openai/v1";
export const DEFAULT_TODO_EXTRACT_TIMEOUT_MS = 120_000;
export const DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS = 12;
// STEP-11: extraction scope. sinceDays = only sessions whose endedAt/startedAt
// falls within the last N days are eligible (the primary scope control). Max
// interactions = per session, keep at most M most-recent interaction records
// (a "turn": one user message through everything before the next). maxSessions
// has no default cap; REST callers can still pass an explicit positive cap.
// stays a backend safety cap (not surfaced as a setting).
export const DEFAULT_TODO_EXTRACT_SINCE_DAYS = 7;
export const DEFAULT_TODO_EXTRACT_MAX_INTERACTIONS = 10;
export const DEFAULT_TODO_EXTRACT_MAX_SESSIONS = Number.POSITIVE_INFINITY;
// Interactive safety cap on how many sessions one extraction pass touches. The
// day window is the primary control, but with the LLM extractor each session is
// a serial sidecar call (up to the per-call timeout), so a single "organize"
// click must stay bounded. REST callers can still pass a larger maxSessions.
export const DEFAULT_TODO_EXTRACT_MAX_SESSIONS = 8;
const LEGACY_LANGEXTRACT_MODELS = new Set(["pa/gpt-5.5"]);
export const WRITABLE_TODO_EXTRACT_KEYS = new Set([
"AGENTMEMORY_TODO_EXTRACTOR",
Expand All @@ -43,7 +46,6 @@ export const WRITABLE_TODO_EXTRACT_KEYS = new Set([
"AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS",
"AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS",
"AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION",
"AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS",
]);

let warnPremiumModelShown = false;
Expand Down Expand Up @@ -94,8 +96,6 @@ export function getTodoExtractorUserConfig(): Record<string, string | boolean> {
LANGEXTRACT_THINKING_DEPTH: env["LANGEXTRACT_THINKING_DEPTH"] || "medium",
AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS:
env["AGENTMEMORY_TODO_EXTRACT_TIMEOUT_MS"] || String(DEFAULT_TODO_EXTRACT_TIMEOUT_MS),
AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS:
env["AGENTMEMORY_TODO_EXTRACT_MAX_LLM_SESSIONS"] || String(DEFAULT_TODO_EXTRACT_MAX_LLM_SESSIONS),
AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS:
env["AGENTMEMORY_TODO_EXTRACT_SINCE_DAYS"] || String(DEFAULT_TODO_EXTRACT_SINCE_DAYS),
AGENTMEMORY_TODO_EXTRACT_MAX_INTERACTIONS_PER_SESSION:
Expand Down
42 changes: 37 additions & 5 deletions src/functions/action-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ export interface ActionCandidateOptions {
type BrowserTurn = { role?: string; text?: string };

const READ_ONLY_TYPES = new Set(["file_read", "search", "web_fetch"]);
const ACTION_VERB_PATTERN = "(修复|补充|实现|调整|验证|排查|定位|跟进|提交|创建|更新|移除|处理|合并|重试|重新(?:运行|跑))";
const ACTION_VERB_PATTERN = "(修复|修正|补充|实现|调整|验证|排查|定位|跟进|整理|生成|上传|提交|创建|更新|移除|删除|处理|审查|合并|推送|构建|重试|重新(?:运行|跑))";
const ENGLISH_ACTION_VERB = "(fix|add|update|create|remove|validate|retry|rerun|re-run|submit|handle|implement|investigate|debug|resolve)";
const ENGLISH_FOLLOW_UP_PATTERN = new RegExp(`\\b(follow up|follow-up|need to|must)\\b.{0,80}\\b${ENGLISH_ACTION_VERB}\\b`, "i");
const ACTION_DESCRIPTION_MAX_LENGTH = 180;
const PROCESS_CHECK_PHRASES = /(?:做最后一次状态确认|最后一次状态确认|启动后做健康检查|做健康检查|健康检查已完成|确认工作区干净|确认当前分支|确认 PR 链接|服务可用|重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i;
const PROCESS_CHECK_REWRITEABLE = /(?:重启 Codex desktop app 后再测一次|重启 Codex desktop app 后再测|重启后再测一次|重启后再测)/i;
const DURABLE_DELIVERABLE_TERMS = new RegExp(ACTION_VERB_PATTERN + `|\\b${ENGLISH_ACTION_VERB}\\b`, "i");

function normalizeText(value: string | undefined): string {
return String(value || "").replace(/\s+/g, " ").trim();
Expand Down Expand Up @@ -140,10 +143,18 @@ function cleanTitle(text: string): string {
return (boundary > 0 ? head.slice(0, boundary) : head).join("").replace(/[,,;;::\s]+$/u, "");
}

function stripActionPrefix(text: string): string {
return normalizeText(text)
.replace(/^(todo|fixme)\s*[::-]\s*/i, "")
.replace(/^(下一步|后续|待办)\s*[::-]?\s*/u, "")
.replace(/^(请|需要|必须)\s*/u, "")
.replace(/^(follow up|follow-up|fix)\s*[::-]?\s*/i, "");
}

function titleFromText(text: string, fallback: string): string {
const followUpMatch = text.match(new RegExp(`(?:下一步|后续)\\s*(?:请|需要|必须)?\\s*(${ACTION_VERB_PATTERN}[^。!?\\n]*)`, "u"))?.[1];
const failureRepairMatch = text.match(new RegExp(`(?:验证未通过|验证失败|测试未通过|测试失败|command failed|exit code [1-9]\\d*|exited with code [1-9]\\d*)[,,。\\s]*(?:请|需要|必须)?\\s*(${ACTION_VERB_PATTERN}[^。!?\\n]*)`, "iu"))?.[1];
const explicit = text.match(/\b(?:TODO|FIXME)\b\s*[::-]\s*([^。!?\n]+)/i)?.[1] ||
const explicit = text.match(/(?:^|[\s((])(?:TODO|FIXME)\b\s*[::-]\s*([^。!?\n]+)/i)?.[1] ||
text.match(/待办\s*[::-]\s*([^。!?\n]+)/u)?.[1] ||
followUpMatch ||
failureRepairMatch ||
Expand Down Expand Up @@ -171,10 +182,31 @@ function isStatusReport(text: string): boolean {
return false;
}

function isProcessCheckText(text: string): boolean {
return PROCESS_CHECK_PHRASES.test(normalizeText(text));
}

function isRewriteableProcessCheck(text: string): boolean {
return PROCESS_CHECK_REWRITEABLE.test(normalizeText(text));
}

function isPureProcessCheck(text: string): boolean {
const normalized = normalizeText(text);
if (!normalized || !isProcessCheckText(normalized)) return false;
if (isRewriteableProcessCheck(normalized)) return false;
return !DURABLE_DELIVERABLE_TERMS.test(stripActionPrefix(normalized).replace(PROCESS_CHECK_PHRASES, ""));
}

function confidenceFor(reason: ActionCandidate["reason"], description: string): number {
if (isRewriteableProcessCheck(description)) return 0.56;
return reason === "follow_up" ? 0.62 : 0.72;
}

function sentenceReason(text: string): ActionCandidate["reason"] | null {
if (isToolTrace(text) || isStatusReport(text)) return null;
if (isPureProcessCheck(text)) return null;
if (/不应生成待办|不要生成待办|无需生成待办|不是待办|not an action|not a todo/i.test(text)) return null;
if (/\b(?:TODO|FIXME)\b\s*[::-]\s*\S/i.test(text) || /待办\s*[::-]\s*\S/u.test(text)) return "todo";
if (/(?:^|[\s((])(?:TODO|FIXME)\b\s*[::-]\s*\S/i.test(text) || /待办\s*[::-]\s*\S/u.test(text)) return "todo";
if (hasExplicitFollowUpAction(text)) return "follow_up";
if (/\bblocked\b|未完成|被阻塞|阻塞/u.test(text)) return "blocked";
if (hasExplicitFailureRepair(text)) return "validation_failed";
Expand Down Expand Up @@ -301,7 +333,7 @@ export function extractActionCandidatesFromObservations(
source: "observation",
sourceObservationIds: [obs.id],
tags: tagsFor(reason),
confidence: reason === "follow_up" ? 0.62 : 0.72,
confidence: confidenceFor(reason, description),
reason,
});
}
Expand Down Expand Up @@ -331,7 +363,7 @@ export function extractActionCandidatesFromTurns(
source: "browser-review",
sourceObservationIds: [],
tags: tagsFor(reason),
confidence: reason === "follow_up" ? 0.62 : 0.72,
confidence: confidenceFor(reason, description),
reason,
});
}
Expand Down
126 changes: 117 additions & 9 deletions src/functions/todo-extract-langextract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,57 @@

PROMPT = textwrap.dedent(
"""\
Extract actionable todos from AI agent session text.
Extract mature, user-facing todo cards from AI agent session text.
When refreshAction metadata is provided, use it as advisory context about the
existing card. Prefer a clearer title/description suggested by cleanup
metadata only when the nearby source quote supports it.
Extract only UNRESOLVED, actionable items: explicit next actions, follow-ups,
failed validations, blocked work, and in-progress work that still needs doing.
DO NOT extract completed work, results, status reports, confirmations, or
narration of what was already done (e.g. "…已通过", "…都能显示", "确认…完成").
Ignore background summaries, generic facts, read-only tool traces, and
anything without a source quote.
Ignore background summaries, generic facts, read-only tool traces, transient
agent process narration, and anything without a source quote.
Use exact source text as extraction_text. Put fields in attributes:
title, description, confidence, timeBucket, typeBucket, dedupeKey.
title must be a CRISP, SPECIFIC action summary — a concrete verb + the
specific object (+ the concrete target/outcome when it adds signal), so each
card's title alone makes clear what THIS todo is. Keep it short, but do not
drop the object or constraint just to hit a character target. DO NOT pad
with vague filler such as
The generated card must read like a mature todo app item, not a transcript
snippet. title must be a CRISP, SCANNABLE user-facing action summary — a
concrete verb + the semantic object (+ the concrete target/outcome when it
adds signal), so the title alone makes clear what THIS todo is. Preserve the
source language unless the source itself mixes languages. Keep it short, but
do not drop the user-relevant object or constraint just to hit a character
target.
Precise technical identifiers must be preserved, but they should usually go
in description rather than the title when they are long: branch names,
commit hashes, file paths, URLs, session ids, package names, and raw repos.
Prefer title "推送当前工作分支到远程仓库" with description
"分支:codex/todo-cleanup-flash-model。" over title
"推送 codex/todo-cleanup-flash-model 分支到远程仓库".
A good title may contain tightly related steps when they serve one outcome,
e.g. "修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染".
description must be one concise sentence about the remaining user-relevant
work; do not start with "I will / 我会 / 现在 / 接下来".
DO NOT pad with vague filler such as
"全面了解 / 了解现状 / 梳理现状 / 获取信息 / 进行 / 处理" — name the actual thing
(the repo, the file, the bug, the command's purpose), not the process of
"understanding" it. Prefer "克隆 AI-Todo 仓库" over "克隆仓库并全面了解其状况".
Do NOT turn agent workflow/status chores into todos unless a durable user
outcome remains. Examples to skip: "做最后一次状态确认", "启动后做健康检查",
"确认工作区干净", "服务可用", "健康检查已完成", "重启后再测一次" when it is just
suggested procedure or stale troubleshooting, and "我会/接下来/现在确认…".
Negative example: source text "我会做最后一次状态确认,确保工作区干净、当前分支和 PR 链接明确。服务可用,健康检查已完成。"
should produce no todo.
If text might be useful but is ambiguous or process-like, set confidence
below the direct-create threshold (0.55-0.81) so it goes to human review.
dedupeKey must be a short STABLE slug of the core action+object (e.g.
clone-aitodo-repo, read-project-config), the SAME for two todos that are the
same task regardless of wording, so reworded duplicates collapse.
Never use raw command JSON, file paths, screenshots, toolUseId/call IDs,
shell flags, logs, or truncated trace fragments as title. If the only source
text is a tool log, command payload, path, or JSON object, do not extract a
todo.
Never extract dangling/truncated titles such as
"准备推送分支 codex/todo-cleanup-flash-model 到"; if context cannot repair
them into a complete, supported action, produce no todo.
Never extract tool-call echo lines (starting with ⏺ or containing Bash(/Shell(),
service-status reports (e.g. "服务可用", "Viewer:"/"Health:" URL lists), or
git-ref fragments — these are not todos.
Expand Down Expand Up @@ -119,6 +146,11 @@ def main() -> int:
print(json.dumps({"todos": []}, ensure_ascii=False))
return 0

refresh_action = payload.get("refreshAction")
if isinstance(refresh_action, dict):
refresh_json = json.dumps(refresh_action, ensure_ascii=False, sort_keys=True)
text = f"[refreshAction]\n{refresh_json}\n\n{text}"

examples = [
lx.data.ExampleData(
text="[obs:obs_1]\n后续需要修复 CI 失败,并重新跑测试。",
Expand Down Expand Up @@ -171,6 +203,74 @@ def main() -> int:
)
],
),
lx.data.ExampleData(
text="[obs:obs_4]\n下一步需要修复深色模式按钮对比度,避免主操作在暗色背景下不可读。",
extractions=[
lx.data.Extraction(
extraction_class="todo",
extraction_text="修复深色模式按钮对比度,避免主操作在暗色背景下不可读",
attributes={
"title": "修复深色模式按钮对比度",
"description": "修复深色模式按钮对比度,避免主操作在暗色背景下不可读。",
"confidence": 0.9,
"timeBucket": "current",
"typeBucket": "follow_up",
"dedupeKey": "fix-dark-mode-button-contrast",
},
)
],
),
lx.data.ExampleData(
text="[obs:obs_5]\n下一步需要推送 codex/todo-cleanup-flash-model 分支到远程仓库。",
extractions=[
lx.data.Extraction(
extraction_class="todo",
extraction_text="推送 codex/todo-cleanup-flash-model 分支到远程仓库",
attributes={
"title": "推送当前工作分支到远程仓库",
"description": "推送当前工作分支到远程仓库。分支:codex/todo-cleanup-flash-model。",
"confidence": 0.88,
"timeBucket": "current",
"typeBucket": "to_start",
"dedupeKey": "push-current-branch",
},
)
],
),
lx.data.ExampleData(
text="[obs:obs_7]\n下一步需要修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染。",
extractions=[
lx.data.Extraction(
extraction_class="todo",
extraction_text="修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染",
attributes={
"title": "修正目录显示文字(去掉重复编号)并更新页码缓存后重渲染",
"description": "修正目录显示文字,去掉重复编号,并更新页码缓存后重渲染。",
"confidence": 0.9,
"timeBucket": "current",
"typeBucket": "follow_up",
"dedupeKey": "fix-toc-numbering-rerender",
},
)
],
),
lx.data.ExampleData(
text="[obs:obs_6]\n建议处理顺序:2. 重启 Codex desktop app 后再测一次。",
extractions=[
lx.data.Extraction(
extraction_class="todo",
extraction_text="重启 Codex desktop app 后再测一次",
attributes={
"title": "验证重启后的 Codex desktop app",
"description": "重启 Codex desktop app 后再验证问题是否仍存在。",
"confidence": 0.65,
"timeBucket": "recent",
"typeBucket": "follow_up",
"dedupeKey": "verify-codex-desktop-after-restart",
},
)
],
),
]
model_id = model_id_from_env()
result = lx.extract(
Expand Down Expand Up @@ -243,7 +343,15 @@ class DummyLx:
assert params["use_schema_constraints"] is False
params = extract_kwargs(DummyLx, "custom/openai-compatible-model", DummyConfig)
assert params["use_schema_constraints"] is False
assert "CRISP, SPECIFIC" in PROMPT
assert "CRISP, SCANNABLE" in PROMPT
assert "codex/todo-cleanup-flash-model" in PROMPT
assert "推送当前工作分支到远程仓库" in PROMPT
assert "修正目录显示文字" in PROMPT
assert "mature todo app item" in PROMPT
assert "做最后一次状态确认" in PROMPT
assert "Negative example" in PROMPT
assert "refreshAction metadata" in PROMPT
assert "0.55-0.81" in PROMPT
assert "dedupeKey must be a short STABLE slug" in PROMPT
assert "克隆 AI-Todo 仓库" in PROMPT
print("ok")
Expand Down
Loading
Loading