qi-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/compaction.py ADDED
@@ -0,0 +1,475 @@
1
+ """上下文压缩与分支摘要(移植 pi 的 `core/compaction`)。
2
+
3
+ 两种机制共用同一套结构化摘要格式与同一条 LLM 调用路径:
4
+
5
+ · **压缩(compaction)**:上下文超过「contextWindow - reserveTokens」或用户 `/compact` 时,
6
+ 把旧消息压成一段结构化摘要,保留最近约 `keepRecentTokens` 的内容;摘要落成
7
+ `type=compaction` entry,带 `firstKeptEntryId`(从哪条 entry 开始保留)。
8
+ · **分支摘要(branch summary)**:`/tree` 跳到别的分支时,把「被放弃的那段」压成摘要挂到
9
+ 新位置(`type=branch_summary`),切回来继续时上下文不丢。
10
+
11
+ 与 pi 一致的两个细节:
12
+ · 切点只落在「turn 边界」或 assistant 消息上,**绝不在 tool 结果上**(它必须跟着它的调用);
13
+ · 单个 turn 就超过预算时做 **split turn**:前缀单独摘要,与历史摘要拼在一起。
14
+
15
+ qi 的差异:`custom`(助手叙述)在 qi 里**不进上下文**(见 runtime._history),所以压缩也当它
16
+ 不可见 —— 与上下文语义保持一致,不额外发明规则。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from .llm import ChatMessage, LLMClient, usage_to_dict
25
+
26
+ DEFAULT_RESERVE_TOKENS = 16384
27
+ """给模型回答留的余量(pi 默认)。"""
28
+
29
+ DEFAULT_KEEP_RECENT_TOKENS = 20000
30
+ """压缩后至少保留的近期内容(pi 默认)。"""
31
+
32
+ SUMMARIZATION_SYSTEM_PROMPT = """You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
33
+
34
+ Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary."""
35
+
36
+ SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
37
+
38
+ Use this EXACT format:
39
+
40
+ ## Goal
41
+ [What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
42
+
43
+ ## Constraints & Preferences
44
+ - [Any constraints, preferences, or requirements mentioned by user]
45
+ - [Or "(none)" if none were mentioned]
46
+
47
+ ## Progress
48
+ ### Done
49
+ - [x] [Completed tasks/changes]
50
+
51
+ ### In Progress
52
+ - [ ] [Current work]
53
+
54
+ ### Blocked
55
+ - [Issues preventing progress, if any]
56
+
57
+ ## Key Decisions
58
+ - **[Decision]**: [Brief rationale]
59
+
60
+ ## Next Steps
61
+ 1. [Ordered list of what should happen next]
62
+
63
+ ## Critical Context
64
+ - [Any data, examples, or references needed to continue]
65
+ - [Or "(none)" if not applicable]
66
+
67
+ Keep each section concise. Preserve exact file paths, function names, and error messages."""
68
+
69
+ UPDATE_SUMMARIZATION_INSTRUCTIONS = """Update the existing structured summary with new information. RULES:
70
+ - PRESERVE all existing information from the previous summary
71
+ - ADD new progress, decisions, and context from the new messages
72
+ - UPDATE the Progress section: move items from "In Progress" to "Done" when completed
73
+ - UPDATE "Next Steps" based on what was accomplished
74
+ - PRESERVE exact file paths, function names, and error messages
75
+ - If something is no longer relevant, you may remove it
76
+
77
+ Use this EXACT format:
78
+
79
+ ## Goal
80
+ [Preserve existing goals, add new ones if the task expanded]
81
+
82
+ ## Constraints & Preferences
83
+ - [Preserve existing, add new if discovered]
84
+
85
+ ## Progress
86
+ ### Done
87
+ - [x] [Include previously done items and newly completed items]
88
+
89
+ ### In Progress
90
+ - [ ] [Current work]
91
+
92
+ ### Blocked
93
+ - [Issues preventing progress, if any]
94
+
95
+ ## Key Decisions
96
+ - **[Decision]**: [Brief rationale]
97
+
98
+ ## Next Steps
99
+ 1. [Updated list of what should happen next]
100
+
101
+ ## Critical Context
102
+ - [Preserve existing and add new important details]"""
103
+
104
+ UPDATE_SUMMARIZATION_PROMPT = f"""The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
105
+
106
+ {UPDATE_SUMMARIZATION_INSTRUCTIONS}"""
107
+
108
+ TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
109
+
110
+ Summarize the prefix to provide context for the retained suffix:
111
+
112
+ ## Original Request
113
+ [What did the user ask for in this turn?]
114
+
115
+ ## Early Progress
116
+ - [Key decisions and work done in the prefix]
117
+
118
+ ## Context for Suffix
119
+ - [Information needed to understand the retained recent work]
120
+
121
+ Be concise. Focus on what's needed to understand the kept suffix."""
122
+
123
+ BRANCH_SUMMARY_PROMPT = """Create a structured summary of this conversation branch for context when returning later.
124
+
125
+ Use this EXACT format:
126
+
127
+ ## Goal
128
+ [What was the user trying to accomplish in this branch?]
129
+
130
+ ## Constraints & Preferences
131
+ - [Any constraints, preferences, or requirements mentioned]
132
+ - [Or "(none)" if none were mentioned]
133
+
134
+ ## Progress
135
+ ### Done
136
+ - [x] [Completed tasks/changes]
137
+
138
+ ### In Progress
139
+ - [ ] [Work that was started but not finished]
140
+
141
+ ### Blocked
142
+ - [Issues preventing progress, if any]
143
+
144
+ ## Key Decisions
145
+ - **[Decision]**: [Brief rationale]
146
+
147
+ ## Next Steps
148
+ 1. [What should happen next to continue this work]
149
+
150
+ Keep each section concise. Preserve exact file paths, function names, and error messages."""
151
+
152
+ BRANCH_SUMMARY_PREAMBLE = """The user explored a different conversation branch before returning here.
153
+ Summary of that exploration:
154
+
155
+ """
156
+
157
+
158
+ # ── 估算(与 pi 同口径:chars/4)──────────────────────────────
159
+
160
+
161
+ def estimate_tokens(text: str) -> int:
162
+ """`chars/4` 保守估计(pi `estimateTokens`)。"""
163
+ return -(-len(text) // 4)
164
+
165
+
166
+ def entry_tokens(entry: dict) -> int:
167
+ """单条 entry 的**上下文** token 估算。
168
+
169
+ 注意 qi 的 tool 结果是 `type=tool` entry,而 `_history` 只把 `message` 送进上下文
170
+ (跨轮不带工具结果 —— 那是 qi 现有设计,不是这里改的),所以工具算 0。
171
+ 摘要输入会带上工具结果(见 `is_summarizable_entry`),因为那是「发生过什么」的事实。
172
+ """
173
+ kind = entry.get("type")
174
+ if kind == "message":
175
+ role = entry.get("role")
176
+ if role == "tool":
177
+ return estimate_tokens(str(entry.get("result") or ""))
178
+ return estimate_tokens(str(entry.get("content") or ""))
179
+ if kind in ("compaction", "branch_summary"):
180
+ return estimate_tokens(str(entry.get("summary") or ""))
181
+ return 0 # tool / dispatch / state / custom(叙述):不进上下文
182
+
183
+
184
+ def is_summarizable_entry(entry: dict) -> bool:
185
+ """会不会被送进**摘要模型**的输入(比上下文多一类:工具结果)。"""
186
+ return entry.get("type") == "tool" or is_context_entry(entry)
187
+
188
+
189
+ def context_tokens(entries: list[dict]) -> int:
190
+ """一段 entry 序列的上下文体积(按 entry 估算)。"""
191
+ return sum(entry_tokens(e) for e in entries)
192
+
193
+
194
+ def messages_tokens(messages: list) -> int:
195
+ """已经组好的上下文消息体积 —— 触发压缩时要用**这个**,而不是原始 entry:
196
+ 压缩过的内容不该再计入(否则会每轮都重复触发)。"""
197
+ return sum(estimate_tokens(str(getattr(m, "content", "") or "")) for m in messages)
198
+
199
+
200
+ def should_compact(tokens: int, context_window: int, *, enabled: bool = True,
201
+ reserve_tokens: int = DEFAULT_RESERVE_TOKENS) -> bool:
202
+ """超了「窗口 - 余量」才压缩(pi `shouldCompact`)。"""
203
+ if not enabled or context_window <= 0:
204
+ return False
205
+ return tokens > context_window - reserve_tokens
206
+
207
+
208
+ # ── 切点(pi `findCutPoint`)────────────────────────────────
209
+
210
+
211
+ def is_context_entry(entry: dict) -> bool:
212
+ """这条 entry 会不会变成**上下文消息**(qi:message / compaction / branch_summary)。"""
213
+ kind = entry.get("type")
214
+ if kind == "message":
215
+ return True
216
+ return kind in ("compaction", "branch_summary")
217
+
218
+
219
+ def is_cut_point(entry: dict) -> bool:
220
+ """可作为「开始保留」的位置:user/assistant 消息,compaction/branch_summary。
221
+
222
+ 绝不在 tool 结果上切 —— 它必须跟在它的调用后面。
223
+ """
224
+ kind = entry.get("type")
225
+ if kind in ("compaction", "branch_summary"):
226
+ return True
227
+ if kind != "message":
228
+ return False
229
+ return entry.get("role") in ("user", "assistant")
230
+
231
+
232
+ def is_turn_start(entry: dict) -> bool:
233
+ """turn 起点:user 消息 / branch_summary / compaction(assistant 与 tool 不是)。"""
234
+ kind = entry.get("type")
235
+ if kind in ("compaction",):
236
+ return False
237
+ if kind == "branch_summary":
238
+ return True
239
+ return kind == "message" and entry.get("role") == "user"
240
+
241
+
242
+ @dataclass
243
+ class Preparation:
244
+ """压缩前的计划(pi `prepareCompaction` 的返回)。"""
245
+
246
+ first_kept_entry_id: str
247
+ messages_to_summarize: list[dict] = field(default_factory=list)
248
+ turn_prefix_messages: list[dict] = field(default_factory=list)
249
+ is_split_turn: bool = False
250
+ tokens_before: int = 0
251
+ previous_summary: str | None = None
252
+
253
+ @property
254
+ def empty(self) -> bool:
255
+ return not self.messages_to_summarize and not self.turn_prefix_messages
256
+
257
+
258
+ def find_cut_point(entries: list[dict], start: int, end: int,
259
+ keep_recent_tokens: int) -> tuple[int, int, bool]:
260
+ """返回 `(first_kept_index, turn_start_index, is_split_turn)`。
261
+
262
+ 算法(pi 同款):从最新往回累加,累计到 `keepRecentTokens` 时,把切点放到**不早于**
263
+ 当前位置的最近一个合法切点;再向前吞掉相邻的不可见 entry(dispatch/state/叙述)。
264
+ """
265
+ cut_points = [i for i in range(start, end) if is_cut_point(entries[i])]
266
+ if not cut_points:
267
+ return start, -1, False
268
+
269
+ accumulated = 0
270
+ cut_index = cut_points[0]
271
+ for i in range(end - 1, start - 1, -1):
272
+ tokens = entry_tokens(entries[i])
273
+ if tokens == 0:
274
+ continue
275
+ accumulated += tokens
276
+ if accumulated >= keep_recent_tokens:
277
+ cut_index = next((c for c in cut_points if c >= i), cut_points[-1])
278
+ break
279
+
280
+ # 向前吞掉不影响上下文的相邻 entry(dispatch/state/叙述),让切点更贴近预算。
281
+ # 注意:工具结果在 qi 里虽然不进上下文,但**不是元数据** —— 不能被吞掉,
282
+ # 否则切点会落在 tool 结果上(它必须跟在它的调用后面)。
283
+ while cut_index > start:
284
+ prev = entries[cut_index - 1]
285
+ if prev.get("type") == "compaction" or is_summarizable_entry(prev):
286
+ break
287
+ cut_index -= 1
288
+
289
+ if is_turn_start(entries[cut_index]):
290
+ return cut_index, -1, False
291
+ turn_start = next((i for i in range(cut_index, start - 1, -1)
292
+ if is_turn_start(entries[i])), -1)
293
+ return cut_index, turn_start, turn_start != -1
294
+
295
+
296
+ def prepare_compaction(branch: list[dict], *, keep_recent_tokens: int = DEFAULT_KEEP_RECENT_TOKENS
297
+ ) -> Preparation | None:
298
+ """算出「要摘要哪些、从哪开始保留」;没什么可压时返回 None。"""
299
+ if not branch:
300
+ return None
301
+ if branch[-1].get("type") == "compaction":
302
+ return None # 刚压过,且之后没有新内容
303
+
304
+ previous_summary: str | None = None
305
+ boundary_start = 0
306
+ prev_index = -1
307
+ for i in range(len(branch) - 1, -1, -1):
308
+ if branch[i].get("type") == "compaction":
309
+ prev_index = i
310
+ break
311
+ if prev_index >= 0:
312
+ previous_summary = str(branch[prev_index].get("summary") or "")
313
+ first_kept = branch[prev_index].get("firstKeptEntryId")
314
+ found = next((i for i, e in enumerate(branch) if str(e.get("id")) == str(first_kept)), -1)
315
+ boundary_start = found if found >= 0 else prev_index + 1
316
+
317
+ cut_index, turn_start, is_split = find_cut_point(
318
+ branch, boundary_start, len(branch), keep_recent_tokens)
319
+ first_kept_entry = branch[cut_index]
320
+ if not first_kept_entry.get("id"):
321
+ return None # 没迁移过的老 entry:不给 id 就没法引用
322
+
323
+ history_end = turn_start if is_split else cut_index
324
+ to_summarize = [e for e in branch[boundary_start:history_end] if is_summarizable_entry(e)]
325
+ turn_prefix: list[dict] = []
326
+ if is_split and turn_start >= 0:
327
+ turn_prefix = [e for e in branch[turn_start:cut_index] if is_summarizable_entry(e)]
328
+
329
+ prep = Preparation(
330
+ first_kept_entry_id=str(first_kept_entry["id"]),
331
+ messages_to_summarize=to_summarize,
332
+ turn_prefix_messages=turn_prefix,
333
+ is_split_turn=is_split,
334
+ tokens_before=context_tokens(branch),
335
+ previous_summary=previous_summary,
336
+ )
337
+ return None if prep.empty else prep
338
+
339
+
340
+ # ── 摘要生成(pi `generateSummary` / `compact`)───────────────
341
+
342
+
343
+ def serialize_conversation(entries: list[dict]) -> str:
344
+ """把 entry 序列摊成纯文本给摘要模型(避免它以为要接着对话)。"""
345
+ parts: list[str] = []
346
+ for entry in entries:
347
+ kind = entry.get("type")
348
+ if kind == "message":
349
+ role = entry.get("role")
350
+ body = str(entry.get("result") if role == "tool" else entry.get("content") or "")
351
+ if not body.strip():
352
+ continue
353
+ who = {"user": "User", "assistant": "Assistant", "tool": "Tool"}.get(str(role), str(role))
354
+ parts.append(f"[{who}]: {body}")
355
+ elif kind == "tool":
356
+ body = str(entry.get("result") or "")
357
+ if body.strip():
358
+ label = str(entry.get("tool") or "tool")
359
+ parts.append(f"[Tool {label}]: {body}")
360
+ elif kind == "compaction":
361
+ parts.append(f"[Previous summary]: {entry.get('summary')}")
362
+ elif kind == "branch_summary":
363
+ parts.append(f"[Branch summary]: {entry.get('summary')}")
364
+ return "\n\n".join(parts)
365
+
366
+
367
+ def summary_context_message(summary: str, *, kind: str = "compaction") -> ChatMessage:
368
+ """把摘要包成一条**上下文消息**(pi 把它放在 system 之后、kept 消息之前)。
369
+
370
+ 用 `user` 角色而不是 system:provider 对「多个 system」支持不一,而摘要本来就是
371
+ 「过去发生的对话」的替身。
372
+ """
373
+ label = "上下文已压缩" if kind == "compaction" else "另一条分支的摘要"
374
+ return ChatMessage(role="user",
375
+ content=f"[{label}]\n{summary}\n[摘要结束,以下是其后的消息]")
376
+
377
+
378
+ async def _summarize(llm: LLMClient, entries: list[dict], *, previous_summary: str | None,
379
+ instructions: str | None, prompt: str) -> tuple[str, dict]:
380
+ text = serialize_conversation(entries)
381
+ body = f"<conversation>\n{text}\n</conversation>\n\n"
382
+ if previous_summary:
383
+ body += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
384
+ base = prompt
385
+ if instructions:
386
+ base = f"{base}\n\nAdditional focus: {instructions}"
387
+ body += base
388
+ resp = await llm.chat([ChatMessage(role="system", content=SUMMARIZATION_SYSTEM_PROMPT),
389
+ ChatMessage(role="user", content=body)])
390
+ if resp.tool_calls:
391
+ raise RuntimeError("摘要模型试图调用工具")
392
+ return (resp.text or "").strip(), resp.usage
393
+
394
+
395
+ async def compact(llm: LLMClient, prep: Preparation, *,
396
+ instructions: str | None = None) -> dict:
397
+ """执行压缩,返回要落盘的 compaction entry(不含 id/parentId/ts)。
398
+
399
+ split turn 时生成两段摘要并拼在一起(pi 同款)。
400
+ """
401
+ if prep.is_split_turn and prep.turn_prefix_messages:
402
+ if prep.messages_to_summarize:
403
+ history, usage = await _summarize(llm, prep.messages_to_summarize,
404
+ previous_summary=prep.previous_summary,
405
+ instructions=instructions,
406
+ prompt=(UPDATE_SUMMARIZATION_PROMPT
407
+ if prep.previous_summary
408
+ else SUMMARIZATION_PROMPT))
409
+ else:
410
+ history, usage = "No prior history.", {}
411
+ prefix, prefix_usage = await _summarize(llm, prep.turn_prefix_messages,
412
+ previous_summary=None,
413
+ instructions=None,
414
+ prompt=TURN_PREFIX_SUMMARIZATION_PROMPT)
415
+ summary = f"{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}"
416
+ merged = dict(prefix_usage)
417
+ for key, value in (usage or {}).items():
418
+ if isinstance(value, (int, float)) and isinstance(merged.get(key), (int, float)):
419
+ merged[key] = merged[key] + value
420
+ else:
421
+ merged[key] = value
422
+ usage = merged
423
+ else:
424
+ summary, usage = await _summarize(llm, prep.messages_to_summarize,
425
+ previous_summary=prep.previous_summary,
426
+ instructions=instructions,
427
+ prompt=(UPDATE_SUMMARIZATION_PROMPT
428
+ if prep.previous_summary
429
+ else SUMMARIZATION_PROMPT))
430
+ return {"type": "compaction", "summary": summary,
431
+ "firstKeptEntryId": prep.first_kept_entry_id,
432
+ "tokensBefore": prep.tokens_before,
433
+ "usage": usage_to_dict(usage)}
434
+
435
+
436
+ async def summarize_branch(llm: LLMClient, entries: list[dict]) -> tuple[str, dict]:
437
+ """分支摘要:把「被放弃的那条分支」压成一段摘要。"""
438
+ summary, usage = await _summarize(llm, entries, previous_summary=None, instructions=None,
439
+ prompt=BRANCH_SUMMARY_PROMPT)
440
+ return summary, usage_to_dict(usage)
441
+
442
+
443
+ def common_ancestor(branch_a: list[dict], branch_b: list[dict]) -> str | None:
444
+ """两条分支(正序 entry 列表)最深的共同祖先 id。"""
445
+ ids_a = [str(e.get("id")) for e in branch_a]
446
+ ids_b = {str(e.get("id")) for e in branch_b}
447
+ for entry_id in reversed(ids_a):
448
+ if entry_id in ids_b:
449
+ return entry_id
450
+ return None
451
+
452
+
453
+ def branch_to_summarize(branch: list[dict], from_id: str | None,
454
+ target_branch: list[dict]) -> list[dict]:
455
+ """要摘要的「被放弃段」:从 `from_id` 往回走到共同祖先(不含祖先)。"""
456
+ ancestor = common_ancestor(branch, target_branch)
457
+ out: list[dict] = []
458
+ by_id = {str(e.get("id")): e for e in branch}
459
+ node = by_id.get(str(from_id)) if from_id else None
460
+ while node is not None:
461
+ node_id = str(node.get("id"))
462
+ if node_id == ancestor:
463
+ break
464
+ if is_context_entry(node):
465
+ out.append(node)
466
+ parent = node.get("parentId")
467
+ node = by_id.get(str(parent)) if parent else None
468
+ out.reverse()
469
+ return out
470
+
471
+
472
+ def usage_int(usage: dict[str, Any] | None, key: str) -> int:
473
+ """usage 字段容错读取(摘要调用也计入会话用量)。"""
474
+ value = (usage or {}).get(key)
475
+ return value if isinstance(value, int) else 0