@event4u/agent-config 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/agent-handoff.md +14 -10
- package/.agent-src/commands/chat-history/import.md +170 -0
- package/.agent-src/commands/chat-history/learn.md +178 -0
- package/.agent-src/commands/chat-history/show.md +17 -18
- package/.agent-src/commands/chat-history.md +26 -25
- package/.agent-src/commands/council/default.md +77 -82
- package/.agent-src/commands/create-pr.md +28 -8
- package/.agent-src/commands/feature/roadmap.md +22 -0
- package/.agent-src/commands/roadmap/create.md +38 -6
- package/.agent-src/commands/roadmap/execute.md +36 -9
- package/.agent-src/commands/sync-gitignore.md +1 -1
- package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
- package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
- package/.agent-src/rules/agent-authority.md +1 -0
- package/.agent-src/rules/agent-docs.md +1 -0
- package/.agent-src/rules/analysis-skill-routing.md +1 -0
- package/.agent-src/rules/architecture.md +1 -0
- package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
- package/.agent-src/rules/artifact-engagement-recording.md +1 -0
- package/.agent-src/rules/ask-when-uncertain.md +1 -0
- package/.agent-src/rules/augment-portability.md +1 -0
- package/.agent-src/rules/augment-source-of-truth.md +1 -0
- package/.agent-src/rules/autonomous-execution.md +1 -0
- package/.agent-src/rules/capture-learnings.md +1 -0
- package/.agent-src/rules/cli-output-handling.md +2 -2
- package/.agent-src/rules/command-suggestion-policy.md +1 -0
- package/.agent-src/rules/commit-conventions.md +1 -0
- package/.agent-src/rules/commit-policy.md +1 -0
- package/.agent-src/rules/context-hygiene.md +22 -0
- package/.agent-src/rules/direct-answers.md +11 -2
- package/.agent-src/rules/docker-commands.md +1 -0
- package/.agent-src/rules/docs-sync.md +1 -0
- package/.agent-src/rules/downstream-changes.md +1 -0
- package/.agent-src/rules/e2e-testing.md +1 -0
- package/.agent-src/rules/guidelines.md +1 -0
- package/.agent-src/rules/improve-before-implement.md +1 -0
- package/.agent-src/rules/language-and-tone.md +38 -6
- package/.agent-src/rules/laravel-translations.md +1 -0
- package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
- package/.agent-src/rules/minimal-safe-diff.md +1 -0
- package/.agent-src/rules/missing-tool-handling.md +1 -0
- package/.agent-src/rules/model-recommendation.md +1 -0
- package/.agent-src/rules/no-attribution-footers.md +48 -0
- package/.agent-src/rules/no-cheap-questions.md +1 -0
- package/.agent-src/rules/no-roadmap-references.md +2 -1
- package/.agent-src/rules/non-destructive-by-default.md +1 -0
- package/.agent-src/rules/onboarding-gate.md +26 -0
- package/.agent-src/rules/package-ci-checks.md +1 -0
- package/.agent-src/rules/php-coding.md +1 -0
- package/.agent-src/rules/preservation-guard.md +1 -0
- package/.agent-src/rules/review-routing-awareness.md +1 -0
- package/.agent-src/rules/reviewer-awareness.md +1 -0
- package/.agent-src/rules/roadmap-progress-sync.md +22 -0
- package/.agent-src/rules/role-mode-adherence.md +2 -2
- package/.agent-src/rules/rule-type-governance.md +1 -0
- package/.agent-src/rules/runtime-safety.md +1 -0
- package/.agent-src/rules/scope-control.md +1 -0
- package/.agent-src/rules/security-sensitive-stop.md +1 -0
- package/.agent-src/rules/size-enforcement.md +1 -0
- package/.agent-src/rules/skill-improvement-trigger.md +1 -0
- package/.agent-src/rules/skill-quality.md +50 -0
- package/.agent-src/rules/slash-command-routing-policy.md +39 -0
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/token-efficiency.md +1 -0
- package/.agent-src/rules/tool-safety.md +1 -0
- package/.agent-src/rules/ui-audit-gate.md +1 -0
- package/.agent-src/rules/upstream-proposal.md +1 -0
- package/.agent-src/rules/user-interaction.md +22 -5
- package/.agent-src/rules/verify-before-complete.md +1 -0
- package/.agent-src/skills/ai-council/SKILL.md +4 -5
- package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
- package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
- package/.agent-src/skills/md-language-check/SKILL.md +1 -1
- package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
- package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
- package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
- package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
- package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
- package/.agent-src/templates/agent-settings.md +21 -26
- package/.agent-src/templates/roadmaps.md +8 -3
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
- package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
- package/.agent-src/templates/skill.md +30 -1
- package/.claude-plugin/marketplace.json +8 -4
- package/AGENTS.md +44 -3
- package/CHANGELOG.md +173 -0
- package/README.md +22 -22
- package/config/agent-settings.template.yml +42 -13
- package/config/gitignore-block.txt +4 -4
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +18 -13
- package/docs/contracts/adr-chat-history-split.md +10 -1
- package/docs/contracts/adr-settings-sync-engine.md +127 -0
- package/docs/contracts/command-clusters.md +1 -1
- package/docs/contracts/cross-wing-handoff.md +133 -0
- package/docs/contracts/decision-trace-v1.md +146 -0
- package/docs/contracts/file-ownership-matrix.json +348 -126
- package/docs/contracts/hook-architecture-v1.md +220 -0
- package/docs/contracts/memory-visibility-v1.md +122 -0
- package/docs/contracts/one-off-script-lifecycle.md +109 -0
- package/docs/contracts/rule-interactions.yml +22 -0
- package/docs/customization.md +2 -1
- package/docs/development.md +4 -1
- package/docs/getting-started.md +21 -29
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +32 -13
- package/docs/hook-payload-capture.md +221 -0
- package/docs/migrations/commands-1.15.0.md +17 -12
- package/docs/skills-catalog.md +5 -4
- package/llms.txt +4 -3
- package/package.json +1 -1
- package/scripts/agent-config +45 -1
- package/scripts/ai_council/_default_prices.py +4 -4
- package/scripts/ai_council/bundler.py +3 -3
- package/scripts/ai_council/clients.py +25 -9
- package/scripts/ai_council/modes.py +3 -4
- package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
- package/scripts/ai_council/pricing.py +10 -9
- package/scripts/ai_council/session.py +92 -0
- package/scripts/build_rule_trigger_matrix.py +1 -9
- package/scripts/capture_showcase_session.py +361 -0
- package/scripts/chat_history.py +963 -597
- package/scripts/check_always_budget.py +7 -2
- package/scripts/check_references.py +12 -2
- package/scripts/context_hygiene_hook.py +14 -6
- package/scripts/council_cli.py +407 -0
- package/scripts/hook_manifest.yaml +217 -0
- package/scripts/hooks/__init__.py +1 -0
- package/scripts/hooks/augment-chat-history.sh +10 -0
- package/scripts/hooks/augment-dispatcher.sh +72 -0
- package/scripts/hooks/cline-dispatcher.sh +86 -0
- package/scripts/hooks/cowork-dispatcher.sh +98 -0
- package/scripts/hooks/cursor-dispatcher.sh +76 -0
- package/scripts/hooks/dispatch_hook.py +383 -0
- package/scripts/hooks/envelope.py +98 -0
- package/scripts/hooks/gemini-dispatcher.sh +117 -0
- package/scripts/hooks/state_io.py +122 -0
- package/scripts/hooks/windsurf-dispatcher.sh +123 -0
- package/scripts/hooks_status.py +157 -0
- package/scripts/install-hooks.sh +2 -2
- package/scripts/install.py +725 -87
- package/scripts/install.sh +38 -1
- package/scripts/lint_handoffs.py +214 -0
- package/scripts/lint_hook_manifest.py +217 -0
- package/scripts/lint_one_off_age.py +184 -0
- package/scripts/lint_rule_tiers.py +78 -0
- package/scripts/lint_showcase_sessions.py +148 -0
- package/scripts/minimal_safe_diff_hook.py +245 -0
- package/scripts/onboarding_gate_hook.py +13 -8
- package/scripts/readme_linter.py +12 -3
- package/scripts/redact_hook_capture.py +148 -0
- package/scripts/roadmap_progress_hook.py +5 -0
- package/scripts/schemas/skill.schema.json +5 -0
- package/scripts/skill_linter.py +163 -1
- package/scripts/sync_agent_settings.py +32 -129
- package/scripts/sync_yaml_rt.py +734 -0
- package/scripts/update_prices.py +3 -3
- package/scripts/verify_before_complete_hook.py +216 -0
- package/.agent-src/commands/chat-history/checkpoint.md +0 -126
- package/.agent-src/commands/chat-history/clear.md +0 -103
- package/.agent-src/commands/chat-history/resume.md +0 -183
- package/.agent-src/rules/chat-history-cadence.md +0 -109
- package/.agent-src/rules/chat-history-ownership.md +0 -123
- package/.agent-src/rules/chat-history-visibility.md +0 -96
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
- package/scripts/check_phase_coupling.py +0 -148
package/scripts/chat_history.py
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Persistent chat-history log for crash recovery.
|
|
3
3
|
|
|
4
|
-
Maintains
|
|
5
|
-
first line is a header (
|
|
6
|
-
whose remaining lines are append-only entries (user
|
|
7
|
-
tool calls, questions, answers, decisions, commits).
|
|
4
|
+
Maintains `agents/.agent-chat-history` — a JSONL file whose
|
|
5
|
+
first line is a header (schema version, started timestamp, cadence
|
|
6
|
+
frequency) and whose remaining lines are append-only entries (user
|
|
7
|
+
messages, phases, tool calls, questions, answers, decisions, commits).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
Sessions are identified per-entry via the `s` field — a deterministic
|
|
10
|
+
16-char prefix derived from the platform's `session_id`. Multiple
|
|
11
|
+
sessions coexist in one file; each entry self-identifies. No ownership
|
|
12
|
+
layer, no sidecar, no auto-adopt — every hook invocation simply appends
|
|
13
|
+
with its own session tag.
|
|
12
14
|
|
|
13
|
-
File path defaults to
|
|
14
|
-
via `$AGENT_CHAT_HISTORY_FILE` (used by tests).
|
|
15
|
+
File path defaults to `agents/.agent-chat-history` (relative to CWD) and
|
|
16
|
+
can be overridden via `$AGENT_CHAT_HISTORY_FILE` (used by tests).
|
|
15
17
|
|
|
16
18
|
Usage:
|
|
17
|
-
python3 scripts/chat_history.py init
|
|
19
|
+
python3 scripts/chat_history.py init [--freq per_phase]
|
|
18
20
|
python3 scripts/chat_history.py append --type phase --json '{...}'
|
|
19
21
|
python3 scripts/chat_history.py status
|
|
20
|
-
python3 scripts/chat_history.py
|
|
21
|
-
python3 scripts/chat_history.py check --first-user-msg "..."
|
|
22
|
-
python3 scripts/chat_history.py state --first-user-msg "..."
|
|
23
|
-
python3 scripts/chat_history.py adopt --first-user-msg "..."
|
|
24
|
-
python3 scripts/chat_history.py reset --first-user-msg "..." --entries-json '[...]' [--freq per_phase]
|
|
22
|
+
python3 scripts/chat_history.py reset --entries-json '[...]' [--freq per_phase]
|
|
25
23
|
python3 scripts/chat_history.py prepend --entries-json '[...]'
|
|
26
|
-
python3 scripts/chat_history.py read [--last N | --all]
|
|
24
|
+
python3 scripts/chat_history.py read [--last N | --all] [--session <id>]
|
|
25
|
+
python3 scripts/chat_history.py sessions [--limit N] [--json]
|
|
26
|
+
python3 scripts/chat_history.py prune-sessions [--max N] [--dry-run]
|
|
27
27
|
python3 scripts/chat_history.py clear
|
|
28
28
|
python3 scripts/chat_history.py rotate --max-kb 256 --mode rotate
|
|
29
29
|
"""
|
|
@@ -38,37 +38,30 @@ import os
|
|
|
38
38
|
import re
|
|
39
39
|
import sys
|
|
40
40
|
import uuid
|
|
41
|
+
from collections import Counter, deque
|
|
41
42
|
from pathlib import Path
|
|
42
43
|
from typing import Any
|
|
43
44
|
|
|
44
|
-
DEFAULT_FILE = "
|
|
45
|
+
DEFAULT_FILE = "agents/.agent-chat-history"
|
|
45
46
|
DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
|
|
46
|
-
SCHEMA_VERSION =
|
|
47
|
-
|
|
47
|
+
SCHEMA_VERSION = 4
|
|
48
|
+
DEFAULT_MAX_SESSIONS = 5
|
|
48
49
|
VALID_FREQS = {"per_turn", "per_phase", "per_tool"}
|
|
49
50
|
VALID_OVERFLOW = {"rotate", "compress"}
|
|
50
51
|
_WS_RE = re.compile(r"\s+")
|
|
52
|
+
SESSION_ID_LEN = 16
|
|
53
|
+
SESSION_ID_UNKNOWN = "<unknown>"
|
|
54
|
+
SESSION_ID_LEGACY = "<legacy>"
|
|
55
|
+
|
|
56
|
+
# Per-entry-type text-length caps. 0 = full text, no whitespace collapse,
|
|
57
|
+
# verbatim. N > 0 = collapse whitespace then slice to N chars and append a
|
|
58
|
+
# "… [+K chars]" suffix so the log self-reports truncation. Overridable via
|
|
59
|
+
# chat_history.text_limits.{user,agent,tool,phase} in .agent-settings.yml.
|
|
60
|
+
DEFAULT_TEXT_LIMITS = {"user": 0, "agent": 5000, "tool": 200, "phase": 200}
|
|
51
61
|
|
|
52
62
|
# Exit codes for the CLI. Distinct codes let shell callers branch on state.
|
|
53
63
|
EXIT_OK = 0
|
|
54
64
|
EXIT_BAD_ARGS = 2
|
|
55
|
-
EXIT_OWNERSHIP_REFUSED = 3
|
|
56
|
-
EXIT_MISSING = 10
|
|
57
|
-
EXIT_FOREIGN = 11
|
|
58
|
-
EXIT_RETURNING = 12
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class OwnershipError(RuntimeError):
|
|
62
|
-
"""Raised when an operation is rejected because the caller's session
|
|
63
|
-
does not own the chat-history file. `state` is one of
|
|
64
|
-
`foreign` | `returning` | `missing`."""
|
|
65
|
-
|
|
66
|
-
def __init__(self, state: str, *, header_fp: str = "",
|
|
67
|
-
current_fp: str = "") -> None:
|
|
68
|
-
super().__init__(f"chat-history ownership refused: state={state}")
|
|
69
|
-
self.state = state
|
|
70
|
-
self.header_fp = header_fp
|
|
71
|
-
self.current_fp = current_fp
|
|
72
65
|
|
|
73
66
|
|
|
74
67
|
def file_path() -> Path:
|
|
@@ -79,22 +72,141 @@ def _now() -> str:
|
|
|
79
72
|
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
|
80
73
|
|
|
81
74
|
|
|
82
|
-
def fingerprint(
|
|
83
|
-
"""SHA-256 of the normalized
|
|
84
|
-
|
|
75
|
+
def fingerprint(value: str) -> str:
|
|
76
|
+
"""SHA-256 of the normalized input (whitespace collapsed).
|
|
77
|
+
|
|
78
|
+
In v4 the input is the platform's ``session_id`` (or any stable
|
|
79
|
+
string). In v3 callers passed the first user message; the function
|
|
80
|
+
is signature-stable so v3 readers continue to work.
|
|
81
|
+
"""
|
|
82
|
+
normalized = _WS_RE.sub(" ", value or "").strip()
|
|
85
83
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
86
84
|
|
|
87
85
|
|
|
86
|
+
def derive_session_tag(session_id: str) -> str:
|
|
87
|
+
"""Map a platform's ``session_id`` to the 16-char ``s`` body tag.
|
|
88
|
+
|
|
89
|
+
Deterministic — same input always yields the same tag, so stateless
|
|
90
|
+
hook invocations within one session converge on a single ``s``
|
|
91
|
+
without needing any cached state on disk.
|
|
92
|
+
"""
|
|
93
|
+
if not session_id:
|
|
94
|
+
return SESSION_ID_UNKNOWN
|
|
95
|
+
return fingerprint(session_id)[:SESSION_ID_LEN]
|
|
96
|
+
|
|
97
|
+
|
|
88
98
|
def _preview(msg: str, n: int = 80) -> str:
|
|
89
99
|
flat = _WS_RE.sub(" ", msg or "").strip()
|
|
90
100
|
return flat[:n]
|
|
91
101
|
|
|
92
102
|
|
|
103
|
+
def _extract_text(obj: dict[str, Any]) -> str:
|
|
104
|
+
"""Return the most-meaningful text payload of an entry, or empty.
|
|
105
|
+
|
|
106
|
+
Mirrors the fallback used by ``list_sessions`` for the ``preview``
|
|
107
|
+
field: top-level ``text`` first, then ``payload.text``.
|
|
108
|
+
"""
|
|
109
|
+
text = obj.get("text")
|
|
110
|
+
if not isinstance(text, str) or not text:
|
|
111
|
+
payload = obj.get("payload")
|
|
112
|
+
if isinstance(payload, dict):
|
|
113
|
+
text = payload.get("text")
|
|
114
|
+
return text if isinstance(text, str) else ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summarize_session(head: list[dict[str, Any]],
|
|
118
|
+
tail: list[dict[str, Any]],
|
|
119
|
+
total: int,
|
|
120
|
+
n: int = 60) -> str:
|
|
121
|
+
"""Build a one-line summary from sampled head/tail entries.
|
|
122
|
+
|
|
123
|
+
Sample = head (≤5 oldest) + tail (≤5 newest), deduplicated by
|
|
124
|
+
object identity (overlap is possible when ``total`` ≤ 9). Format:
|
|
125
|
+
|
|
126
|
+
- both first and last user prose: ``"<first> → <last>"``
|
|
127
|
+
- one user prose only / both same: ``"<first>"``
|
|
128
|
+
- no user prose: ``"(<total> entries — no user
|
|
129
|
+
prompts; t-mix: …)"``
|
|
130
|
+
|
|
131
|
+
Each side capped at ``n`` chars via :func:`_preview`. Designed for
|
|
132
|
+
token-cheap session listings — caller never needs the full body.
|
|
133
|
+
"""
|
|
134
|
+
seen: set[int] = set()
|
|
135
|
+
sample: list[dict[str, Any]] = []
|
|
136
|
+
for e in list(head) + list(tail):
|
|
137
|
+
oid = id(e)
|
|
138
|
+
if oid in seen:
|
|
139
|
+
continue
|
|
140
|
+
seen.add(oid)
|
|
141
|
+
sample.append(e)
|
|
142
|
+
|
|
143
|
+
user_texts = [
|
|
144
|
+
_extract_text(e)
|
|
145
|
+
for e in sample
|
|
146
|
+
if e.get("t") == "user" and _extract_text(e)
|
|
147
|
+
]
|
|
148
|
+
if user_texts:
|
|
149
|
+
first = _preview(user_texts[0], n)
|
|
150
|
+
if len(user_texts) > 1 and user_texts[-1] != user_texts[0]:
|
|
151
|
+
last = _preview(user_texts[-1], n)
|
|
152
|
+
return f"{first} → {last}"
|
|
153
|
+
return first
|
|
154
|
+
|
|
155
|
+
kinds = Counter(e.get("t", "?") for e in sample)
|
|
156
|
+
mix = " ".join(f"{k}×{v}" for k, v in kinds.most_common())
|
|
157
|
+
return f"({total} entries — no user prompts; t-mix: {mix})"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _session_tag_enabled() -> bool:
|
|
161
|
+
"""True iff `append()` should auto-fill the `s` field when missing.
|
|
162
|
+
|
|
163
|
+
Default is on (v3+ contract). Kill-switch via
|
|
164
|
+
`AGENT_CHAT_HISTORY_SESSION_TAG=false` reverts to v2 entry shape
|
|
165
|
+
so a bad rollout can be reverted without code change.
|
|
166
|
+
"""
|
|
167
|
+
return os.environ.get(
|
|
168
|
+
"AGENT_CHAT_HISTORY_SESSION_TAG", "true"
|
|
169
|
+
).strip().lower() != "false"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _last_body_session_id(path: Path | None = None) -> str:
|
|
173
|
+
"""Return the ``s`` of the most recent body entry, or ``<unknown>``.
|
|
174
|
+
|
|
175
|
+
Used as a fallback ``s`` for CLI-driven appends that have no
|
|
176
|
+
platform session context. Reads the file tail-first to keep the
|
|
177
|
+
cost constant on large logs.
|
|
178
|
+
"""
|
|
179
|
+
p = path or file_path()
|
|
180
|
+
if not p.is_file() or p.stat().st_size == 0:
|
|
181
|
+
return SESSION_ID_UNKNOWN
|
|
182
|
+
try:
|
|
183
|
+
with p.open(encoding="utf-8") as fh:
|
|
184
|
+
lines = fh.readlines()
|
|
185
|
+
except OSError:
|
|
186
|
+
return SESSION_ID_UNKNOWN
|
|
187
|
+
for line in reversed(lines):
|
|
188
|
+
line = line.strip()
|
|
189
|
+
if not line:
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
obj = json.loads(line)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
continue
|
|
195
|
+
if not isinstance(obj, dict) or obj.get("t") == "header":
|
|
196
|
+
continue
|
|
197
|
+
sid = obj.get("s")
|
|
198
|
+
if isinstance(sid, str) and sid:
|
|
199
|
+
return sid
|
|
200
|
+
return SESSION_ID_UNKNOWN
|
|
201
|
+
|
|
202
|
+
|
|
93
203
|
def read_header(path: Path | None = None) -> dict[str, Any] | None:
|
|
94
|
-
"""Read the header.
|
|
204
|
+
"""Read the header.
|
|
95
205
|
|
|
96
|
-
|
|
97
|
-
|
|
206
|
+
Forward-compatible: v3 headers (`fp`, `preview`, `former_fps`,
|
|
207
|
+
`session`) parse fine; their legacy fields are returned verbatim
|
|
208
|
+
so older readers keep working. The next write (init/reset)
|
|
209
|
+
rewrites the file with a clean v4 header.
|
|
98
210
|
"""
|
|
99
211
|
p = path or file_path()
|
|
100
212
|
if not p.is_file() or p.stat().st_size == 0:
|
|
@@ -107,142 +219,121 @@ def read_header(path: Path | None = None) -> dict[str, Any] | None:
|
|
|
107
219
|
obj = json.loads(first)
|
|
108
220
|
if not (isinstance(obj, dict) and obj.get("t") == "header"):
|
|
109
221
|
return None
|
|
110
|
-
obj.setdefault("former_fps", [])
|
|
111
|
-
if not isinstance(obj["former_fps"], list):
|
|
112
|
-
obj["former_fps"] = []
|
|
113
222
|
return obj
|
|
114
223
|
except (json.JSONDecodeError, OSError):
|
|
115
224
|
return None
|
|
116
225
|
|
|
117
226
|
|
|
118
|
-
def _build_header(
|
|
119
|
-
former_fps: list[str] | None = None) -> dict[str, Any]:
|
|
227
|
+
def _build_header(freq: str) -> dict[str, Any]:
|
|
120
228
|
return {
|
|
121
229
|
"t": "header",
|
|
122
230
|
"v": SCHEMA_VERSION,
|
|
123
|
-
"session": str(uuid.uuid4()),
|
|
124
231
|
"started": _now(),
|
|
125
|
-
"fp": fingerprint(first_user_msg),
|
|
126
|
-
"preview": _preview(first_user_msg),
|
|
127
232
|
"freq": freq,
|
|
128
|
-
"former_fps": list(former_fps or []),
|
|
129
233
|
}
|
|
130
234
|
|
|
131
235
|
|
|
132
|
-
def init(
|
|
236
|
+
def init(freq: str = "per_phase", *,
|
|
133
237
|
path: Path | None = None) -> dict[str, Any]:
|
|
134
|
-
"""Overwrite the file with a fresh header
|
|
238
|
+
"""Overwrite the file with a fresh v4 header."""
|
|
135
239
|
if freq not in VALID_FREQS:
|
|
136
240
|
raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
|
|
137
241
|
p = path or file_path()
|
|
138
|
-
header = _build_header(
|
|
242
|
+
header = _build_header(freq)
|
|
139
243
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
140
244
|
with p.open("w", encoding="utf-8") as fh:
|
|
141
245
|
fh.write(json.dumps(header, ensure_ascii=False) + "\n")
|
|
142
246
|
return header
|
|
143
247
|
|
|
144
248
|
|
|
249
|
+
def migrate_header(path: Path | None = None, *,
|
|
250
|
+
freq: str | None = None) -> dict[str, Any] | None:
|
|
251
|
+
"""Rewrite a stale header in-place, preserving body and ``started``.
|
|
252
|
+
|
|
253
|
+
Returns the new header on migration, ``None`` when the file is
|
|
254
|
+
missing/empty/unreadable or the header is already at
|
|
255
|
+
:data:`SCHEMA_VERSION`. v3 headers carry parseable ``v``/``freq``/
|
|
256
|
+
``started`` fields that are forward-compatible (see
|
|
257
|
+
:func:`read_header`); this helper is the only writer that flips
|
|
258
|
+
``v`` without destroying the body. Atomic — the body never
|
|
259
|
+
diverges from the header version mid-write.
|
|
260
|
+
"""
|
|
261
|
+
p = path or file_path()
|
|
262
|
+
existing = read_header(p)
|
|
263
|
+
if existing is None:
|
|
264
|
+
return None
|
|
265
|
+
try:
|
|
266
|
+
current_v = int(existing.get("v", 0))
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
current_v = 0
|
|
269
|
+
if current_v >= SCHEMA_VERSION:
|
|
270
|
+
return None
|
|
271
|
+
chosen_freq = freq or existing.get("freq") or "per_phase"
|
|
272
|
+
if chosen_freq not in VALID_FREQS:
|
|
273
|
+
chosen_freq = "per_phase"
|
|
274
|
+
new_header = _build_header(chosen_freq)
|
|
275
|
+
# Preserve the original session start so chronology survives.
|
|
276
|
+
if isinstance(existing.get("started"), str):
|
|
277
|
+
new_header["started"] = existing["started"]
|
|
278
|
+
raw = p.read_text(encoding="utf-8")
|
|
279
|
+
# splitlines() drops the trailing newline; rebuild it on write so
|
|
280
|
+
# downstream readers (which expect newline-delimited JSONL) stay
|
|
281
|
+
# happy. Empty body → just the header line + newline.
|
|
282
|
+
lines = raw.splitlines()
|
|
283
|
+
if not lines:
|
|
284
|
+
return None
|
|
285
|
+
lines[0] = json.dumps(new_header, ensure_ascii=False)
|
|
286
|
+
_atomic_write_text(p, "\n".join(lines) + "\n")
|
|
287
|
+
return new_header
|
|
288
|
+
|
|
289
|
+
|
|
145
290
|
def append(entry: dict[str, Any], *, path: Path | None = None,
|
|
146
|
-
|
|
291
|
+
session: str | None = None) -> None:
|
|
147
292
|
"""Append one entry. Entry must be a dict; `ts` is auto-filled.
|
|
148
293
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
is
|
|
153
|
-
|
|
154
|
-
|
|
294
|
+
Schema v4 stamps every body entry with `s` (16-char session tag).
|
|
295
|
+
Resolution order: caller-supplied `session=` wins; pre-filled
|
|
296
|
+
`entry['s']` is preserved; otherwise the most recent body entry's
|
|
297
|
+
`s` is reused (CLI fallback). Kill-switch
|
|
298
|
+
`AGENT_CHAT_HISTORY_SESSION_TAG=false` skips auto-fill for
|
|
299
|
+
downgrade-friendly rollouts.
|
|
300
|
+
|
|
301
|
+
No ownership validation: each entry self-identifies via `s`, so
|
|
302
|
+
multiple sessions coexist in one file without conflict.
|
|
155
303
|
"""
|
|
156
304
|
if not isinstance(entry, dict) or not entry.get("t"):
|
|
157
305
|
raise ValueError("entry must be a dict with non-empty 't' key")
|
|
158
306
|
if entry["t"] == "header":
|
|
159
307
|
raise ValueError("use init() to write the header, not append()")
|
|
160
308
|
p = path or file_path()
|
|
161
|
-
if first_user_msg is not None:
|
|
162
|
-
state = ownership_state(first_user_msg, path=p)
|
|
163
|
-
if state != "match":
|
|
164
|
-
header = read_header(p) or {}
|
|
165
|
-
raise OwnershipError(
|
|
166
|
-
state,
|
|
167
|
-
header_fp=str(header.get("fp", "")),
|
|
168
|
-
current_fp=fingerprint(first_user_msg),
|
|
169
|
-
)
|
|
170
309
|
entry.setdefault("ts", _now())
|
|
310
|
+
if session is not None:
|
|
311
|
+
entry["s"] = session
|
|
312
|
+
elif "s" not in entry and _session_tag_enabled():
|
|
313
|
+
entry["s"] = _last_body_session_id(p)
|
|
171
314
|
with p.open("a", encoding="utf-8") as fh:
|
|
172
315
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
173
316
|
|
|
174
317
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
"""Return 'match', 'mismatch', or 'missing' (legacy 3-state).
|
|
318
|
+
def _atomic_write_text(p: Path, text: str) -> None:
|
|
319
|
+
"""Write ``text`` to ``p`` atomically with a per-call unique tmp path.
|
|
178
320
|
|
|
179
|
-
|
|
180
|
-
|
|
321
|
+
Multiple processes writing to the same target use disjoint tmp paths
|
|
322
|
+
(PID + uuid), so concurrent writes no longer collide on a shared
|
|
323
|
+
``.tmp`` file. The final ``replace`` is atomic on POSIX.
|
|
181
324
|
"""
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return "missing"
|
|
185
|
-
return "match" if header.get("fp") == fingerprint(first_user_msg) else "mismatch"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def ownership_state(first_user_msg: str, *,
|
|
189
|
-
path: Path | None = None) -> str:
|
|
190
|
-
"""Return 'match', 'returning', 'foreign', or 'missing'.
|
|
191
|
-
|
|
192
|
-
`match` — current fp equals header.fp (silent append)
|
|
193
|
-
`returning` — current fp appears in header.former_fps (this chat once
|
|
194
|
-
owned the file; another session took it over since)
|
|
195
|
-
`foreign` — current fp is neither match nor former (new chat finds
|
|
196
|
-
an existing file from an unknown session)
|
|
197
|
-
`missing` — no file or no valid header
|
|
198
|
-
"""
|
|
199
|
-
header = read_header(path)
|
|
200
|
-
if not header:
|
|
201
|
-
return "missing"
|
|
202
|
-
fp = fingerprint(first_user_msg)
|
|
203
|
-
if header.get("fp") == fp:
|
|
204
|
-
return "match"
|
|
205
|
-
former = header.get("former_fps") or []
|
|
206
|
-
return "returning" if fp in former else "foreign"
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def _push_former_fp(former_fps: list[str], old_fp: str,
|
|
210
|
-
new_fp: str) -> list[str]:
|
|
211
|
-
"""Move old_fp into former_fps with dedup + cap. Never include new_fp."""
|
|
212
|
-
seen: list[str] = []
|
|
213
|
-
for fp in [old_fp, *former_fps]:
|
|
214
|
-
if fp and fp != new_fp and fp not in seen:
|
|
215
|
-
seen.append(fp)
|
|
216
|
-
return seen[:FORMER_FPS_CAP]
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def adopt(first_user_msg: str, *, path: Path | None = None) -> dict[str, Any]:
|
|
220
|
-
"""Rewrite the header's fingerprint to the current conversation's.
|
|
221
|
-
|
|
222
|
-
Preserves all body entries. Pushes the previous `fp` onto
|
|
223
|
-
`former_fps` (dedup, capped at FORMER_FPS_CAP) so this former owner
|
|
224
|
-
can later be detected as 'returning' if the original chat comes back.
|
|
225
|
-
"""
|
|
226
|
-
p = path or file_path()
|
|
227
|
-
header = read_header(p)
|
|
228
|
-
if not header:
|
|
229
|
-
raise FileNotFoundError(f"no header in {p}")
|
|
230
|
-
old_fp = header.get("fp", "")
|
|
231
|
-
new_fp = fingerprint(first_user_msg)
|
|
232
|
-
header["v"] = SCHEMA_VERSION
|
|
233
|
-
header["fp"] = new_fp
|
|
234
|
-
header["preview"] = _preview(first_user_msg)
|
|
235
|
-
header["adopted_at"] = _now()
|
|
236
|
-
header["former_fps"] = _push_former_fp(
|
|
237
|
-
header.get("former_fps") or [], old_fp, new_fp,
|
|
325
|
+
tmp = p.with_suffix(
|
|
326
|
+
f"{p.suffix}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp",
|
|
238
327
|
)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
328
|
+
try:
|
|
329
|
+
tmp.write_text(text, encoding="utf-8")
|
|
330
|
+
tmp.replace(p)
|
|
331
|
+
except Exception:
|
|
332
|
+
try:
|
|
333
|
+
tmp.unlink()
|
|
334
|
+
except OSError:
|
|
335
|
+
pass
|
|
336
|
+
raise
|
|
246
337
|
|
|
247
338
|
|
|
248
339
|
def _normalize_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
@@ -259,40 +350,25 @@ def _normalize_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
259
350
|
return out
|
|
260
351
|
|
|
261
352
|
|
|
262
|
-
def reset_with_entries(
|
|
263
|
-
entries: list[dict[str, Any]],
|
|
353
|
+
def reset_with_entries(entries: list[dict[str, Any]],
|
|
264
354
|
freq: str = "per_phase", *,
|
|
265
|
-
former_fps: list[str] | None = None,
|
|
266
355
|
path: Path | None = None) -> dict[str, Any]:
|
|
267
356
|
"""Discard current file contents and rewrite with a fresh header + entries.
|
|
268
357
|
|
|
269
|
-
Used for the 'Replace' flow: the in-memory history supersedes
|
|
270
|
-
is on disk.
|
|
271
|
-
|
|
272
|
-
|
|
358
|
+
Used for the 'Replace' flow: the in-memory history supersedes
|
|
359
|
+
whatever is on disk. v4 carries no per-session header state, so
|
|
360
|
+
the rewrite is a clean slate; pre-existing entries' ``s`` tags
|
|
361
|
+
survive only if the caller passes them through ``entries``.
|
|
273
362
|
"""
|
|
274
363
|
if freq not in VALID_FREQS:
|
|
275
364
|
raise ValueError(f"freq must be one of {sorted(VALID_FREQS)}")
|
|
276
365
|
p = path or file_path()
|
|
277
|
-
|
|
278
|
-
if former_fps is None:
|
|
279
|
-
existing = read_header(p)
|
|
280
|
-
if existing:
|
|
281
|
-
former_fps = _push_former_fp(
|
|
282
|
-
existing.get("former_fps") or [],
|
|
283
|
-
existing.get("fp", ""),
|
|
284
|
-
new_fp,
|
|
285
|
-
)
|
|
286
|
-
else:
|
|
287
|
-
former_fps = []
|
|
288
|
-
header = _build_header(first_user_msg, freq, former_fps=former_fps)
|
|
366
|
+
header = _build_header(freq)
|
|
289
367
|
body = _normalize_entries(entries)
|
|
290
368
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
291
369
|
lines = [json.dumps(header, ensure_ascii=False)]
|
|
292
370
|
lines += [json.dumps(e, ensure_ascii=False) for e in body]
|
|
293
|
-
|
|
294
|
-
tmp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
295
|
-
tmp.replace(p)
|
|
371
|
+
_atomic_write_text(p, "\n".join(lines) + "\n")
|
|
296
372
|
return header
|
|
297
373
|
|
|
298
374
|
|
|
@@ -315,10 +391,9 @@ def prepend_entries(entries: list[dict[str, Any]], *,
|
|
|
315
391
|
body = existing[1:]
|
|
316
392
|
new_lines = [json.dumps(e, ensure_ascii=False) + "\n"
|
|
317
393
|
for e in _normalize_entries(entries)]
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
tmp.replace(p)
|
|
394
|
+
_atomic_write_text(
|
|
395
|
+
p, header_line + "".join(new_lines) + "".join(body),
|
|
396
|
+
)
|
|
322
397
|
return len(new_lines)
|
|
323
398
|
|
|
324
399
|
|
|
@@ -329,10 +404,15 @@ def clear(*, path: Path | None = None) -> None:
|
|
|
329
404
|
|
|
330
405
|
|
|
331
406
|
def read_entries(last: int | None = None, *,
|
|
332
|
-
path: Path | None = None
|
|
407
|
+
path: Path | None = None,
|
|
408
|
+
session: str | None = None) -> list[dict[str, Any]]:
|
|
333
409
|
"""Return entries (excluding the header) as a list of dicts.
|
|
334
410
|
|
|
335
411
|
`last=None` returns all entries; `last=N` returns the trailing N.
|
|
412
|
+
`session=None` keeps legacy "return everything" behaviour; an explicit
|
|
413
|
+
string filters by exact match on each entry's `s` field. The `last`
|
|
414
|
+
slice is applied **after** the session filter so callers always get
|
|
415
|
+
the trailing N within the selected session.
|
|
336
416
|
Malformed lines are skipped silently.
|
|
337
417
|
"""
|
|
338
418
|
p = path or file_path()
|
|
@@ -352,11 +432,119 @@ def read_entries(last: int | None = None, *,
|
|
|
352
432
|
continue
|
|
353
433
|
if isinstance(obj, dict):
|
|
354
434
|
entries.append(obj)
|
|
435
|
+
if session is not None:
|
|
436
|
+
entries = [e for e in entries if e.get("s") == session]
|
|
355
437
|
if last is not None and last >= 0:
|
|
356
438
|
entries = entries[-last:]
|
|
357
439
|
return entries
|
|
358
440
|
|
|
359
441
|
|
|
442
|
+
def read_entries_for_current(path: Path | None = None,
|
|
443
|
+
last: int | None = None) -> list[dict[str, Any]]:
|
|
444
|
+
"""Return entries scoped to the most recent session in the file.
|
|
445
|
+
|
|
446
|
+
The "current" session in v4 is the ``s`` of the most recent body
|
|
447
|
+
entry; entries with that ``s`` are returned. Kill-switch
|
|
448
|
+
``AGENT_CHAT_HISTORY_SESSION_FILTER=false`` short-circuits to
|
|
449
|
+
``read_entries(session=None)`` for the v2 "return everything"
|
|
450
|
+
behaviour.
|
|
451
|
+
"""
|
|
452
|
+
p = path or file_path()
|
|
453
|
+
kill = os.environ.get(
|
|
454
|
+
"AGENT_CHAT_HISTORY_SESSION_FILTER", "true",
|
|
455
|
+
).strip().lower()
|
|
456
|
+
if kill == "false":
|
|
457
|
+
return read_entries(last=last, path=p, session=None)
|
|
458
|
+
return read_entries(last=last, path=p, session=_last_body_session_id(p))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def list_sessions(path: Path | None = None,
|
|
462
|
+
*, summary: bool = False) -> list[dict[str, Any]]:
|
|
463
|
+
"""Return one bucket per distinct session id observed in the body.
|
|
464
|
+
|
|
465
|
+
Each bucket carries ``id``, ``count``, ``first_ts``, ``last_ts``,
|
|
466
|
+
``preview``. Preview = the first ``t == "user"`` entry's ``text``
|
|
467
|
+
in the session, truncated to 80 chars; falls back to the first
|
|
468
|
+
entry of any type when no user-typed entry exists.
|
|
469
|
+
|
|
470
|
+
When ``summary=True``, each bucket also carries a ``summary`` field
|
|
471
|
+
built from at most 10 sampled entries per session (5 oldest + 5
|
|
472
|
+
newest, deduplicated). Designed for token-cheap listings: callers
|
|
473
|
+
can render ``summary`` instead of pulling all entries via
|
|
474
|
+
:func:`read`. See :func:`_summarize_session` for the format.
|
|
475
|
+
|
|
476
|
+
v4 has no per-session header state, so buckets are derived from
|
|
477
|
+
body ``s`` values only. ``<legacy>`` and ``<unknown>`` appear as
|
|
478
|
+
their own buckets when present in the body. Order is by
|
|
479
|
+
``last_ts`` descending.
|
|
480
|
+
"""
|
|
481
|
+
p = path or file_path()
|
|
482
|
+
buckets: dict[str, dict[str, Any]] = {}
|
|
483
|
+
|
|
484
|
+
def _bucket(sid: str) -> dict[str, Any]:
|
|
485
|
+
b = buckets.get(sid)
|
|
486
|
+
if b is None:
|
|
487
|
+
b = {"id": sid, "count": 0, "first_ts": None,
|
|
488
|
+
"last_ts": None, "preview": ""}
|
|
489
|
+
if summary:
|
|
490
|
+
b["_head"] = []
|
|
491
|
+
b["_tail"] = deque(maxlen=5)
|
|
492
|
+
buckets[sid] = b
|
|
493
|
+
return b
|
|
494
|
+
|
|
495
|
+
if p.is_file():
|
|
496
|
+
with p.open(encoding="utf-8") as fh:
|
|
497
|
+
for i, line in enumerate(fh):
|
|
498
|
+
line = line.strip()
|
|
499
|
+
if not line:
|
|
500
|
+
continue
|
|
501
|
+
try:
|
|
502
|
+
obj = json.loads(line)
|
|
503
|
+
except json.JSONDecodeError:
|
|
504
|
+
continue
|
|
505
|
+
if not isinstance(obj, dict):
|
|
506
|
+
continue
|
|
507
|
+
if i == 0 and obj.get("t") == "header":
|
|
508
|
+
continue
|
|
509
|
+
sid = obj.get("s")
|
|
510
|
+
if not isinstance(sid, str) or not sid:
|
|
511
|
+
sid = SESSION_ID_LEGACY
|
|
512
|
+
b = _bucket(sid)
|
|
513
|
+
b["count"] += 1
|
|
514
|
+
ts = obj.get("ts")
|
|
515
|
+
if isinstance(ts, str) and ts:
|
|
516
|
+
if b["first_ts"] is None or ts < b["first_ts"]:
|
|
517
|
+
b["first_ts"] = ts
|
|
518
|
+
if b["last_ts"] is None or ts > b["last_ts"]:
|
|
519
|
+
b["last_ts"] = ts
|
|
520
|
+
if summary:
|
|
521
|
+
if len(b["_head"]) < 5:
|
|
522
|
+
b["_head"].append(obj)
|
|
523
|
+
b["_tail"].append(obj)
|
|
524
|
+
if not b["preview"] or b.get("_preview_from") != "user":
|
|
525
|
+
if obj.get("t") == "user":
|
|
526
|
+
text = obj.get("text") or obj.get("payload", {}).get("text", "")
|
|
527
|
+
if isinstance(text, str) and text:
|
|
528
|
+
b["preview"] = _preview(text)
|
|
529
|
+
b["_preview_from"] = "user"
|
|
530
|
+
elif not b["preview"]:
|
|
531
|
+
text = obj.get("text") or ""
|
|
532
|
+
if isinstance(text, str) and text:
|
|
533
|
+
b["preview"] = _preview(text)
|
|
534
|
+
b["_preview_from"] = "any"
|
|
535
|
+
|
|
536
|
+
out: list[dict[str, Any]] = []
|
|
537
|
+
for b in buckets.values():
|
|
538
|
+
b.pop("_preview_from", None)
|
|
539
|
+
if summary:
|
|
540
|
+
head = b.pop("_head", [])
|
|
541
|
+
tail = list(b.pop("_tail", ()))
|
|
542
|
+
b["summary"] = _summarize_session(head, tail, b["count"])
|
|
543
|
+
out.append(b)
|
|
544
|
+
out.sort(key=lambda x: x["last_ts"] or "", reverse=True)
|
|
545
|
+
return out
|
|
546
|
+
|
|
547
|
+
|
|
360
548
|
def status(*, path: Path | None = None) -> dict[str, Any]:
|
|
361
549
|
p = path or file_path()
|
|
362
550
|
if not p.is_file():
|
|
@@ -400,9 +588,6 @@ def _read_chat_history_enabled(settings_path: Path) -> bool:
|
|
|
400
588
|
return bool(section.get("enabled", False))
|
|
401
589
|
|
|
402
590
|
|
|
403
|
-
VALID_HEARTBEAT_MODES = ("on", "off", "hybrid")
|
|
404
|
-
DRIFT_STATES = ("missing", "foreign", "returning")
|
|
405
|
-
|
|
406
591
|
# Hook events that the platform-hook wrapper accepts. Mapped to entry
|
|
407
592
|
# types in HOOK_EVENT_ENTRY_TYPE; cadence filtering in
|
|
408
593
|
# CADENCE_EVENTS decides whether the event actually lands in the log
|
|
@@ -423,7 +608,7 @@ HOOK_EVENT_ENTRY_TYPE = {
|
|
|
423
608
|
# events are control plane (sidecar / init), not log entries, so they
|
|
424
609
|
# are absent from these sets.
|
|
425
610
|
CADENCE_EVENTS = {
|
|
426
|
-
"per_turn": frozenset({"stop", "agent_response"}),
|
|
611
|
+
"per_turn": frozenset({"stop", "agent_response", "user_prompt"}),
|
|
427
612
|
"per_phase": frozenset({"phase", "stop", "user_prompt"}),
|
|
428
613
|
"per_tool": frozenset({"tool_use"}),
|
|
429
614
|
}
|
|
@@ -442,6 +627,30 @@ PLATFORM_EVENT_MAP: dict[str, dict[str, str]] = {
|
|
|
442
627
|
"SessionEnd": "session_end",
|
|
443
628
|
"PreCompact": "phase",
|
|
444
629
|
},
|
|
630
|
+
# Cowork is the Claude desktop app's local-agent-mode runtime —
|
|
631
|
+
# built on top of the Claude Code CLI, so it speaks the same hook
|
|
632
|
+
# vocabulary (PascalCase, identical event payload shape including
|
|
633
|
+
# `transcript_path` for Stop). Listed as a separate platform so the
|
|
634
|
+
# `agent` field on body entries can distinguish Cowork sessions
|
|
635
|
+
# from plain Claude Code CLI / IDE sessions when both run against
|
|
636
|
+
# the same project.
|
|
637
|
+
#
|
|
638
|
+
# Upstream caveat: anthropics/claude-code#40495 reports that
|
|
639
|
+
# Cowork sessions silently ignore all three Claude Code settings
|
|
640
|
+
# sources (user, project, env), and #27398 reports plugin-scope
|
|
641
|
+
# `hooks/hooks.json` is excluded because Cowork spawns the CLI
|
|
642
|
+
# with `--setting-sources user`. Until those are resolved, the
|
|
643
|
+
# mapping below is dispatcher-ready but the lifecycle events do
|
|
644
|
+
# not actually fire from Cowork. See
|
|
645
|
+
# `agents/contexts/chat-history-platform-hooks.md` § Cowork.
|
|
646
|
+
"cowork": {
|
|
647
|
+
"SessionStart": "session_start",
|
|
648
|
+
"UserPromptSubmit": "user_prompt",
|
|
649
|
+
"PostToolUse": "tool_use",
|
|
650
|
+
"Stop": "stop",
|
|
651
|
+
"SessionEnd": "session_end",
|
|
652
|
+
"PreCompact": "phase",
|
|
653
|
+
},
|
|
445
654
|
"augment": {
|
|
446
655
|
"SessionStart": "session_start",
|
|
447
656
|
"Stop": "stop",
|
|
@@ -502,217 +711,152 @@ def _read_chat_history_frequency(settings_path: Path) -> str:
|
|
|
502
711
|
return val if val in VALID_FREQS else "per_phase"
|
|
503
712
|
|
|
504
713
|
|
|
505
|
-
def
|
|
506
|
-
"""
|
|
714
|
+
def _read_chat_history_max_sessions(settings_path: Path) -> int:
|
|
715
|
+
"""Read chat_history.max_sessions from .agent-settings.yml.
|
|
507
716
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
"""
|
|
512
|
-
base = path or file_path()
|
|
513
|
-
return base.with_name(base.name + ".session")
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def read_sidecar(path: Path | None = None) -> dict[str, Any] | None:
|
|
517
|
-
"""Read and parse the sidecar; returns None on missing or malformed."""
|
|
518
|
-
sp = sidecar_path(path)
|
|
519
|
-
if not sp.is_file():
|
|
520
|
-
return None
|
|
521
|
-
try:
|
|
522
|
-
with sp.open(encoding="utf-8") as fh:
|
|
523
|
-
data = json.load(fh)
|
|
524
|
-
return data if isinstance(data, dict) else None
|
|
525
|
-
except (OSError, json.JSONDecodeError):
|
|
526
|
-
return None
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def write_sidecar(first_user_msg: str, *,
|
|
530
|
-
path: Path | None = None) -> dict[str, Any]:
|
|
531
|
-
"""Write the session sidecar atomically. Overwrites on session_start."""
|
|
532
|
-
sp = sidecar_path(path)
|
|
533
|
-
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
534
|
-
payload = {
|
|
535
|
-
"first_user_msg": first_user_msg,
|
|
536
|
-
"fp": fingerprint(first_user_msg),
|
|
537
|
-
"started_at": _now(),
|
|
538
|
-
}
|
|
539
|
-
tmp = sp.with_suffix(sp.suffix + ".tmp")
|
|
540
|
-
with tmp.open("w", encoding="utf-8") as fh:
|
|
541
|
-
json.dump(payload, fh, ensure_ascii=False)
|
|
542
|
-
tmp.replace(sp)
|
|
543
|
-
return payload
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def _read_chat_history_heartbeat_mode(settings_path: Path) -> str:
|
|
547
|
-
"""Read chat_history.heartbeat from .agent-settings.yml.
|
|
548
|
-
|
|
549
|
-
Returns one of 'on' | 'off' | 'hybrid'. Default 'hybrid' (marker
|
|
550
|
-
surfaces only on drift states — missing/foreign/returning — and
|
|
551
|
-
stays silent on 'ok'/'disabled'). Unknown values fall back to
|
|
552
|
-
'hybrid'. Mirrors the default-deny policy of `_read_chat_history_enabled`
|
|
553
|
-
for the `enabled` flag, but here the default is the safer-by-design
|
|
554
|
-
hybrid mode rather than off.
|
|
717
|
+
Default ``DEFAULT_MAX_SESSIONS`` (5). Values < 1 are clamped to 1.
|
|
718
|
+
Used by ``prune_sessions`` to decide how many distinct ``s`` tags
|
|
719
|
+
survive in the body.
|
|
555
720
|
"""
|
|
556
721
|
if not settings_path.is_file():
|
|
557
|
-
return
|
|
722
|
+
return DEFAULT_MAX_SESSIONS
|
|
558
723
|
try:
|
|
559
724
|
import yaml # type: ignore[import-untyped]
|
|
560
725
|
except ImportError:
|
|
561
|
-
return
|
|
726
|
+
return DEFAULT_MAX_SESSIONS
|
|
562
727
|
try:
|
|
563
728
|
with settings_path.open(encoding="utf-8") as fh:
|
|
564
729
|
data = yaml.safe_load(fh) or {}
|
|
565
730
|
except (OSError, yaml.YAMLError):
|
|
566
|
-
return
|
|
731
|
+
return DEFAULT_MAX_SESSIONS
|
|
567
732
|
section = data.get("chat_history") if isinstance(data, dict) else None
|
|
568
733
|
if not isinstance(section, dict):
|
|
569
|
-
return
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if raw is False:
|
|
576
|
-
return "off"
|
|
577
|
-
val = str(raw).lower()
|
|
578
|
-
if val in VALID_HEARTBEAT_MODES:
|
|
579
|
-
return val
|
|
580
|
-
return "hybrid"
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def turn_check(first_user_msg: str, *, path: Path | None = None,
|
|
584
|
-
settings_path: Path | None = None) -> dict[str, Any]:
|
|
585
|
-
"""Compute the turn-start ownership state.
|
|
586
|
-
|
|
587
|
-
Returns a structured dict the CLI renders to stdout/stderr. Pure
|
|
588
|
-
function — no I/O outside the two paths it reads.
|
|
589
|
-
"""
|
|
590
|
-
sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
591
|
-
if not _read_chat_history_enabled(sp):
|
|
592
|
-
return {"state": "disabled", "exit": EXIT_OK}
|
|
593
|
-
p = path or file_path()
|
|
594
|
-
state = ownership_state(first_user_msg, path=p)
|
|
595
|
-
if state == "match":
|
|
596
|
-
st = status(path=p)
|
|
597
|
-
return {
|
|
598
|
-
"state": "ok",
|
|
599
|
-
"exit": EXIT_OK,
|
|
600
|
-
"entries": st.get("entries", 0),
|
|
601
|
-
}
|
|
602
|
-
header = read_header(p) or {}
|
|
603
|
-
out: dict[str, Any] = {
|
|
604
|
-
"state": state,
|
|
605
|
-
"current_fp": fingerprint(first_user_msg),
|
|
606
|
-
"header_fp": str(header.get("fp", "")),
|
|
607
|
-
"preview": str(header.get("preview", "")),
|
|
608
|
-
}
|
|
609
|
-
if state == "missing":
|
|
610
|
-
out["exit"] = EXIT_MISSING
|
|
611
|
-
elif state == "foreign":
|
|
612
|
-
out["exit"] = EXIT_FOREIGN
|
|
613
|
-
st = status(path=p)
|
|
614
|
-
out["entries"] = st.get("entries", 0)
|
|
615
|
-
else: # returning
|
|
616
|
-
out["exit"] = EXIT_RETURNING
|
|
617
|
-
st = status(path=p)
|
|
618
|
-
out["entries"] = st.get("entries", 0)
|
|
619
|
-
return out
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def _format_age(seconds: int) -> str:
|
|
623
|
-
"""Render a relative duration as a compact human-readable string."""
|
|
624
|
-
if seconds < 0:
|
|
625
|
-
return "just now"
|
|
626
|
-
if seconds < 60:
|
|
627
|
-
return f"{seconds}s ago"
|
|
628
|
-
if seconds < 3600:
|
|
629
|
-
return f"{seconds // 60}m ago"
|
|
630
|
-
if seconds < 86400:
|
|
631
|
-
return f"{seconds // 3600}h ago"
|
|
632
|
-
return f"{seconds // 86400}d ago"
|
|
734
|
+
return DEFAULT_MAX_SESSIONS
|
|
735
|
+
try:
|
|
736
|
+
n = int(section.get("max_sessions", DEFAULT_MAX_SESSIONS))
|
|
737
|
+
except (TypeError, ValueError):
|
|
738
|
+
return DEFAULT_MAX_SESSIONS
|
|
739
|
+
return max(1, n)
|
|
633
740
|
|
|
634
741
|
|
|
635
|
-
def
|
|
636
|
-
"""
|
|
742
|
+
def _read_text_limits(settings_path: Path) -> dict[str, int]:
|
|
743
|
+
"""Read chat_history.text_limits from .agent-settings.yml.
|
|
637
744
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
745
|
+
Returns a dict keyed by entry type (``user``, ``agent``, ``tool``,
|
|
746
|
+
``phase``) with int caps. Missing keys fall back to
|
|
747
|
+
``DEFAULT_TEXT_LIMITS``. ``0`` means "no slice, full text". Negative
|
|
748
|
+
values are clamped to 0. Non-int values are silently dropped.
|
|
642
749
|
"""
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
try:
|
|
647
|
-
with path.open(encoding="utf-8") as fh:
|
|
648
|
-
for raw in fh:
|
|
649
|
-
stripped = raw.strip()
|
|
650
|
-
if stripped:
|
|
651
|
-
last_line = stripped
|
|
652
|
-
except OSError:
|
|
653
|
-
return None
|
|
654
|
-
if not last_line:
|
|
655
|
-
return None
|
|
750
|
+
out = dict(DEFAULT_TEXT_LIMITS)
|
|
751
|
+
if not settings_path.is_file():
|
|
752
|
+
return out
|
|
656
753
|
try:
|
|
657
|
-
|
|
658
|
-
except
|
|
659
|
-
return
|
|
660
|
-
if not isinstance(obj, dict) or obj.get("t") == "header":
|
|
661
|
-
return None
|
|
662
|
-
ts = obj.get("ts")
|
|
663
|
-
if not ts or not isinstance(ts, str):
|
|
664
|
-
return None
|
|
754
|
+
import yaml # type: ignore[import-untyped]
|
|
755
|
+
except ImportError:
|
|
756
|
+
return out
|
|
665
757
|
try:
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
758
|
+
with settings_path.open(encoding="utf-8") as fh:
|
|
759
|
+
data = yaml.safe_load(fh) or {}
|
|
760
|
+
except (OSError, yaml.YAMLError):
|
|
761
|
+
return out
|
|
762
|
+
section = data.get("chat_history") if isinstance(data, dict) else None
|
|
763
|
+
if not isinstance(section, dict):
|
|
764
|
+
return out
|
|
765
|
+
overrides = section.get("text_limits")
|
|
766
|
+
if not isinstance(overrides, dict):
|
|
767
|
+
return out
|
|
768
|
+
for kind, val in overrides.items():
|
|
769
|
+
if not isinstance(kind, str):
|
|
770
|
+
continue
|
|
771
|
+
try:
|
|
772
|
+
n = int(val)
|
|
773
|
+
except (TypeError, ValueError):
|
|
774
|
+
continue
|
|
775
|
+
out[kind] = max(0, n)
|
|
776
|
+
return out
|
|
674
777
|
|
|
675
|
-
def heartbeat(first_user_msg: str, *, path: Path | None = None,
|
|
676
|
-
settings_path: Path | None = None) -> dict[str, Any]:
|
|
677
|
-
"""Compute the in-band reply marker proving the rule was executed.
|
|
678
778
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
across two replies at `per_turn`/`per_phase`) is immediately
|
|
683
|
-
visible to the user without any out-of-band tooling.
|
|
779
|
+
def _apply_text_limit(text: str, kind: str,
|
|
780
|
+
limits: dict[str, int]) -> str:
|
|
781
|
+
"""Slice ``text`` to the configured cap for ``kind``.
|
|
684
782
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
783
|
+
``limits[kind] == 0`` returns the text verbatim (whitespace
|
|
784
|
+
preserved). ``> 0`` collapses whitespace, slices to N chars, and
|
|
785
|
+
appends ``" … [+K chars]"`` when truncation actually happened so
|
|
786
|
+
the log self-reports the cut. Empty / missing kind falls back to
|
|
787
|
+
``DEFAULT_TEXT_LIMITS``.
|
|
688
788
|
"""
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
789
|
+
if not text:
|
|
790
|
+
return ""
|
|
791
|
+
n = limits.get(kind, DEFAULT_TEXT_LIMITS.get(kind, 0))
|
|
792
|
+
if n <= 0:
|
|
793
|
+
return text
|
|
794
|
+
flat = _WS_RE.sub(" ", text).strip()
|
|
795
|
+
if len(flat) <= n:
|
|
796
|
+
return flat
|
|
797
|
+
return f"{flat[:n]} … [+{len(flat) - n} chars]"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def prune_sessions(max_sessions: int = DEFAULT_MAX_SESSIONS, *,
|
|
801
|
+
path: Path | None = None) -> dict[str, Any]:
|
|
802
|
+
"""Keep only the ``max_sessions`` most-recent sessions in the body.
|
|
803
|
+
|
|
804
|
+
Recency is the body line index of a session's last entry — the body
|
|
805
|
+
is append-only, so position is canonical (and stable when multiple
|
|
806
|
+
sessions share a wall-clock second). The trailing ``max_sessions``
|
|
807
|
+
win, the rest of their entries are dropped. Header untouched.
|
|
808
|
+
``<unknown>`` and ``<legacy>`` count as ordinary sessions for the
|
|
809
|
+
purpose of this cap.
|
|
810
|
+
|
|
811
|
+
Returns ``{action, kept_sessions, dropped_sessions, dropped_entries}``.
|
|
812
|
+
Noop when the file is missing, has no body, or carries fewer than
|
|
813
|
+
``max_sessions`` distinct sessions.
|
|
814
|
+
"""
|
|
815
|
+
if max_sessions < 1:
|
|
816
|
+
max_sessions = 1
|
|
693
817
|
p = path or file_path()
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
818
|
+
if not p.is_file():
|
|
819
|
+
return {"action": "noop", "kept_sessions": 0,
|
|
820
|
+
"dropped_sessions": 0, "dropped_entries": 0}
|
|
821
|
+
with p.open(encoding="utf-8") as fh:
|
|
822
|
+
lines = fh.readlines()
|
|
823
|
+
if len(lines) <= 1:
|
|
824
|
+
return {"action": "noop", "kept_sessions": 0,
|
|
825
|
+
"dropped_sessions": 0, "dropped_entries": 0}
|
|
826
|
+
header_line = lines[0]
|
|
827
|
+
body = lines[1:]
|
|
828
|
+
# Rank sessions by body position — last appearance wins. Body is
|
|
829
|
+
# append-only, so position is canonical recency; ts is only a
|
|
830
|
+
# secondary signal (tied on second-level resolution in practice).
|
|
831
|
+
last_pos: dict[str, int] = {}
|
|
832
|
+
parsed: list[tuple[str, str]] = [] # (sid, raw_line)
|
|
833
|
+
for idx, line in enumerate(body):
|
|
834
|
+
stripped = line.strip()
|
|
835
|
+
if not stripped:
|
|
836
|
+
continue
|
|
837
|
+
try:
|
|
838
|
+
obj = json.loads(stripped)
|
|
839
|
+
except json.JSONDecodeError:
|
|
840
|
+
parsed.append((SESSION_ID_LEGACY, line))
|
|
841
|
+
last_pos[SESSION_ID_LEGACY] = idx
|
|
842
|
+
continue
|
|
843
|
+
if not isinstance(obj, dict):
|
|
844
|
+
continue
|
|
845
|
+
sid = obj.get("s") if isinstance(obj.get("s"), str) else SESSION_ID_LEGACY
|
|
846
|
+
parsed.append((sid, line))
|
|
847
|
+
last_pos[sid] = idx
|
|
848
|
+
if len(last_pos) <= max_sessions:
|
|
849
|
+
return {"action": "noop", "kept_sessions": len(last_pos),
|
|
850
|
+
"dropped_sessions": 0, "dropped_entries": 0}
|
|
851
|
+
ranked = sorted(last_pos.items(), key=lambda kv: kv[1], reverse=True)
|
|
852
|
+
keep_set = {sid for sid, _ in ranked[:max_sessions]}
|
|
853
|
+
drop_set = {sid for sid, _ in ranked[max_sessions:]}
|
|
854
|
+
kept_lines = [line for sid, line in parsed if sid in keep_set]
|
|
855
|
+
dropped_entries = len(parsed) - len(kept_lines)
|
|
856
|
+
_atomic_write_text(p, header_line + "".join(kept_lines))
|
|
857
|
+
return {"action": "pruned", "kept_sessions": len(keep_set),
|
|
858
|
+
"dropped_sessions": len(drop_set),
|
|
859
|
+
"dropped_entries": dropped_entries}
|
|
716
860
|
|
|
717
861
|
|
|
718
862
|
def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
@@ -746,9 +890,7 @@ def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
|
746
890
|
total += size
|
|
747
891
|
kept.reverse()
|
|
748
892
|
dropped = len(entries) - len(kept)
|
|
749
|
-
|
|
750
|
-
tmp.write_text(header_line + "".join(kept), encoding="utf-8")
|
|
751
|
-
tmp.replace(p)
|
|
893
|
+
_atomic_write_text(p, header_line + "".join(kept))
|
|
752
894
|
return {"action": "rotate", "kept": len(kept), "dropped": dropped}
|
|
753
895
|
marker = {
|
|
754
896
|
"t": "needs_compress",
|
|
@@ -760,25 +902,39 @@ def overflow_handle(max_kb: int, mode: str = "rotate", *,
|
|
|
760
902
|
|
|
761
903
|
|
|
762
904
|
def hook_append(event: str, *,
|
|
763
|
-
|
|
905
|
+
session_id: str | None = None,
|
|
764
906
|
payload: dict[str, Any] | None = None,
|
|
765
907
|
path: Path | None = None,
|
|
766
908
|
settings_path: Path | None = None) -> dict[str, Any]:
|
|
767
|
-
"""Platform-hook entry point —
|
|
768
|
-
|
|
769
|
-
Designed for
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
909
|
+
"""Platform-hook entry point — stateless append per session tag.
|
|
910
|
+
|
|
911
|
+
Designed for ``SessionStart``, ``UserPromptSubmit``, ``PostToolUse``,
|
|
912
|
+
``Stop``, ``SessionEnd`` style hooks. Each call derives an ``s`` tag
|
|
913
|
+
from ``session_id`` via :func:`derive_session_tag`; entries from
|
|
914
|
+
different sessions coexist in one file because every body line
|
|
915
|
+
self-identifies. No sidecar, no ownership, no auto-adopt.
|
|
916
|
+
|
|
917
|
+
The first non-disabled call to this function on a missing/empty
|
|
918
|
+
file initialises the v4 header. ``session_start`` is otherwise a
|
|
919
|
+
control-plane noop — useful only as an explicit hint that a new
|
|
920
|
+
session is about to begin (and to trigger pruning of old
|
|
921
|
+
sessions). All other events go through cadence filtering and
|
|
922
|
+
append a body entry whose ``text`` is sliced per
|
|
923
|
+
:func:`_apply_text_limit`.
|
|
924
|
+
|
|
925
|
+
Pruning: when the incoming ``s`` is new (differs from the most
|
|
926
|
+
recent body entry's ``s``), :func:`prune_sessions` runs with
|
|
927
|
+
``chat_history.max_sessions`` so the file never accumulates more
|
|
928
|
+
than the configured number of distinct sessions. The prune is a
|
|
929
|
+
noop when the cap is not reached.
|
|
930
|
+
|
|
931
|
+
Cadence-aware: events that don't match ``chat_history.frequency``
|
|
932
|
+
are silently skipped. ``enabled: false`` short-circuits to a noop.
|
|
777
933
|
|
|
778
934
|
Returns a structured dict the CLI emits as JSON. Never raises for
|
|
779
|
-
non-fatal control-plane states (
|
|
780
|
-
|
|
781
|
-
fail_open vs fail_closed by inspecting the result.
|
|
935
|
+
non-fatal control-plane states (cadence skip, disabled,
|
|
936
|
+
unknown-session) — these surface as ``action`` values so hooks
|
|
937
|
+
can choose fail_open vs fail_closed by inspecting the result.
|
|
782
938
|
"""
|
|
783
939
|
if event not in VALID_HOOK_EVENTS:
|
|
784
940
|
raise ValueError(f"event must be one of {sorted(VALID_HOOK_EVENTS)}")
|
|
@@ -787,62 +943,296 @@ def hook_append(event: str, *,
|
|
|
787
943
|
return {"action": "disabled", "event": event}
|
|
788
944
|
p = path or file_path()
|
|
789
945
|
payload = payload or {}
|
|
946
|
+
s_tag = derive_session_tag(session_id) if session_id else SESSION_ID_UNKNOWN
|
|
947
|
+
|
|
948
|
+
# Lazily initialise the v4 header on first use so callers don't
|
|
949
|
+
# have to invoke `init` separately. Reset is still an explicit
|
|
950
|
+
# operation via reset_with_entries / clear. When the file already
|
|
951
|
+
# has a parseable but stale header (v3 in the wild), rewrite the
|
|
952
|
+
# header in-place — body is preserved, version flips to v4. Without
|
|
953
|
+
# this branch, v3 headers parse as non-None and the lazy-init path
|
|
954
|
+
# never fires, leaving the file in a mixed v3-header / v4-body
|
|
955
|
+
# state forever.
|
|
956
|
+
if not p.is_file() or read_header(p) is None:
|
|
957
|
+
freq = _read_chat_history_frequency(sp)
|
|
958
|
+
init(freq=freq, path=p)
|
|
959
|
+
else:
|
|
960
|
+
migrate_header(p, freq=_read_chat_history_frequency(sp))
|
|
961
|
+
|
|
962
|
+
# Detect session change BEFORE appending so the new entry's `s`
|
|
963
|
+
# doesn't shadow the previous one. Actual prune fires AFTER the
|
|
964
|
+
# append so the cap is enforced against the post-append body
|
|
965
|
+
# (otherwise the effective cap would be max_sessions + 1).
|
|
966
|
+
is_new_session = (
|
|
967
|
+
s_tag != SESSION_ID_UNKNOWN
|
|
968
|
+
and _last_body_session_id(p) != s_tag
|
|
969
|
+
)
|
|
790
970
|
|
|
791
|
-
|
|
792
|
-
if not
|
|
793
|
-
return
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
"fp": fingerprint(first_user_msg)}
|
|
800
|
-
return {"action": "sidecar_written", "event": event,
|
|
801
|
-
"fp": fingerprint(first_user_msg)}
|
|
802
|
-
|
|
803
|
-
side = read_sidecar(p)
|
|
804
|
-
fum = first_user_msg or (side or {}).get("first_user_msg")
|
|
805
|
-
if not fum:
|
|
806
|
-
return {"action": "skipped_no_sidecar", "event": event,
|
|
807
|
-
"hint": "session_start hook never ran or sidecar was deleted"}
|
|
971
|
+
def _maybe_prune() -> None:
|
|
972
|
+
if not is_new_session:
|
|
973
|
+
return
|
|
974
|
+
max_n = _read_chat_history_max_sessions(sp)
|
|
975
|
+
try:
|
|
976
|
+
prune_sessions(max_n, path=p)
|
|
977
|
+
except OSError as exc:
|
|
978
|
+
sys.stderr.write(f"chat-history prune_failed: {exc}\n")
|
|
808
979
|
|
|
980
|
+
if event == "session_start":
|
|
981
|
+
_maybe_prune()
|
|
982
|
+
return {"action": "session_start_noop", "event": event, "s": s_tag}
|
|
809
983
|
if event == "session_end":
|
|
810
|
-
|
|
811
|
-
return {"action": "session_end_noop", "event": event}
|
|
984
|
+
_maybe_prune()
|
|
985
|
+
return {"action": "session_end_noop", "event": event, "s": s_tag}
|
|
812
986
|
|
|
813
987
|
freq = _read_chat_history_frequency(sp)
|
|
814
988
|
if event not in CADENCE_EVENTS.get(freq, frozenset()):
|
|
815
|
-
return {"action": "skipped_cadence", "event": event,
|
|
989
|
+
return {"action": "skipped_cadence", "event": event,
|
|
990
|
+
"frequency": freq}
|
|
816
991
|
|
|
817
992
|
entry_type = HOOK_EVENT_ENTRY_TYPE.get(event, "agent")
|
|
993
|
+
limits = _read_text_limits(sp)
|
|
818
994
|
entry: dict[str, Any] = {"t": entry_type}
|
|
819
|
-
text = str(payload.get("text", ""))
|
|
995
|
+
text = str(payload.get("text", ""))
|
|
820
996
|
if text:
|
|
821
|
-
|
|
997
|
+
sliced = _apply_text_limit(text, entry_type, limits)
|
|
998
|
+
if sliced:
|
|
999
|
+
entry["text"] = sliced
|
|
822
1000
|
if event == "tool_use":
|
|
823
1001
|
tool = payload.get("tool")
|
|
824
1002
|
if tool:
|
|
825
1003
|
entry["tool"] = str(tool)
|
|
826
|
-
for k in ("source", "phase", "decision"):
|
|
1004
|
+
for k in ("agent", "source", "phase", "decision"):
|
|
827
1005
|
if payload.get(k):
|
|
828
1006
|
entry[k] = str(payload[k])
|
|
1007
|
+
append(entry, path=p, session=s_tag)
|
|
1008
|
+
_maybe_prune()
|
|
1009
|
+
return {"action": "appended", "event": event,
|
|
1010
|
+
"type": entry_type, "s": s_tag}
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _extract_augment_conversation(
|
|
1014
|
+
payload: dict[str, Any],
|
|
1015
|
+
) -> tuple[str, str]:
|
|
1016
|
+
"""Return ``(user_prompt, agent_response)`` from an Augment payload.
|
|
1017
|
+
|
|
1018
|
+
Augment Code with ``includeConversationData: true`` nests the
|
|
1019
|
+
turn under ``conversation`` (``userPrompt`` + ``agentTextResponse``).
|
|
1020
|
+
Returns empty strings when the block is absent or malformed.
|
|
1021
|
+
"""
|
|
1022
|
+
conv = payload.get("conversation")
|
|
1023
|
+
if not isinstance(conv, dict):
|
|
1024
|
+
return ("", "")
|
|
1025
|
+
user = conv.get("userPrompt")
|
|
1026
|
+
agent = conv.get("agentTextResponse")
|
|
1027
|
+
user_s = user.strip() if isinstance(user, str) else ""
|
|
1028
|
+
agent_s = agent.strip() if isinstance(agent, str) else ""
|
|
1029
|
+
return (user_s, agent_s)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _extract_claude_transcript_response(transcript_path: str) -> str:
|
|
1033
|
+
"""Read Claude Code's JSONL transcript and return the last assistant text.
|
|
1034
|
+
|
|
1035
|
+
Claude Code's ``Stop`` hook payload only carries ``session_id`` and
|
|
1036
|
+
``transcript_path``; the actual response lives inside the JSONL file
|
|
1037
|
+
as a sequence of ``{"type": "assistant", "message": {"content": …}}``
|
|
1038
|
+
entries. Best-effort: silently returns ``""`` on missing file, decode
|
|
1039
|
+
error, or unexpected shape so the caller falls back to other paths.
|
|
1040
|
+
"""
|
|
1041
|
+
if not transcript_path:
|
|
1042
|
+
return ""
|
|
1043
|
+
p = Path(transcript_path)
|
|
1044
|
+
if not p.is_file():
|
|
1045
|
+
return ""
|
|
1046
|
+
last_text = ""
|
|
829
1047
|
try:
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1048
|
+
with p.open(encoding="utf-8") as fh:
|
|
1049
|
+
for line in fh:
|
|
1050
|
+
line = line.strip()
|
|
1051
|
+
if not line:
|
|
1052
|
+
continue
|
|
1053
|
+
try:
|
|
1054
|
+
obj = json.loads(line)
|
|
1055
|
+
except json.JSONDecodeError:
|
|
1056
|
+
continue
|
|
1057
|
+
if not isinstance(obj, dict):
|
|
1058
|
+
continue
|
|
1059
|
+
if obj.get("type") != "assistant":
|
|
1060
|
+
continue
|
|
1061
|
+
msg = obj.get("message")
|
|
1062
|
+
if not isinstance(msg, dict):
|
|
1063
|
+
continue
|
|
1064
|
+
content = msg.get("content")
|
|
1065
|
+
if isinstance(content, str):
|
|
1066
|
+
last_text = content
|
|
1067
|
+
elif isinstance(content, list):
|
|
1068
|
+
parts: list[str] = []
|
|
1069
|
+
for blk in content:
|
|
1070
|
+
if (isinstance(blk, dict)
|
|
1071
|
+
and blk.get("type") == "text"):
|
|
1072
|
+
t = blk.get("text", "")
|
|
1073
|
+
if isinstance(t, str):
|
|
1074
|
+
parts.append(t)
|
|
1075
|
+
if parts:
|
|
1076
|
+
last_text = "\n".join(parts)
|
|
1077
|
+
except OSError:
|
|
1078
|
+
return ""
|
|
1079
|
+
return last_text.strip()
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _extract_cursor_text(
|
|
1083
|
+
payload: dict[str, Any], event: str | None,
|
|
1084
|
+
) -> str:
|
|
1085
|
+
"""Cursor hook payload extractor (docs-verified, 2026-05).
|
|
1086
|
+
|
|
1087
|
+
Cursor's ``afterAgentResponse`` and ``stop`` hooks ship a
|
|
1088
|
+
``transcript_path`` pointing at a Claude-format JSONL file (Cursor
|
|
1089
|
+
reuses Claude Code's transcript schema). For ``beforeSubmitPrompt``
|
|
1090
|
+
the prompt is in the top-level ``prompt`` key. The fallback walker
|
|
1091
|
+
handles both, but we route here so the transcript is preferred over
|
|
1092
|
+
any stale top-level field.
|
|
1093
|
+
|
|
1094
|
+
Sources: <https://cursor.com/docs/hooks>,
|
|
1095
|
+
<https://cursor.com/docs/reference/third-party-hooks>.
|
|
1096
|
+
"""
|
|
1097
|
+
if event in ("stop", "agent_response"):
|
|
1098
|
+
tp = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
1099
|
+
if isinstance(tp, str):
|
|
1100
|
+
txt = _extract_claude_transcript_response(tp)
|
|
1101
|
+
if txt:
|
|
1102
|
+
return txt
|
|
1103
|
+
return ""
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _extract_cline_text(
|
|
1107
|
+
payload: dict[str, Any], event: str | None,
|
|
1108
|
+
) -> str:
|
|
1109
|
+
"""Cline hook payload extractor (docs-verified, 2026-05).
|
|
837
1110
|
|
|
1111
|
+
Cline ships PascalCase event names (``UserPromptSubmit``,
|
|
1112
|
+
``TaskComplete``) but body keys are camelCase. ``UserPromptSubmit``
|
|
1113
|
+
carries the prompt as ``prompt``; ``TaskComplete`` is mapped to
|
|
1114
|
+
``session_end`` (no body text emitted by default). The top-level
|
|
1115
|
+
fallback already covers ``prompt``, but we route here so future
|
|
1116
|
+
schema extensions land in one place.
|
|
838
1117
|
|
|
839
|
-
|
|
1118
|
+
Sources: <https://docs.cline.bot/customization/hooks>,
|
|
1119
|
+
<https://docs.cline.bot/features/hooks>.
|
|
1120
|
+
"""
|
|
1121
|
+
if event == "user_prompt":
|
|
1122
|
+
v = payload.get("prompt") or payload.get("userPrompt")
|
|
1123
|
+
if isinstance(v, str) and v.strip():
|
|
1124
|
+
return v.strip()
|
|
1125
|
+
return ""
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _extract_gemini_text(
|
|
1129
|
+
payload: dict[str, Any], event: str | None,
|
|
1130
|
+
) -> str:
|
|
1131
|
+
"""Gemini CLI hook payload extractor (docs-verified, 2026-05).
|
|
1132
|
+
|
|
1133
|
+
Gemini CLI's ``AfterAgent`` payload carries the agent text directly
|
|
1134
|
+
as ``prompt_response`` (snake_case, matching the rest of Gemini's
|
|
1135
|
+
hook keys). When absent, the dispatcher may still receive a
|
|
1136
|
+
``transcript_path`` — Gemini transcripts use the same JSONL shape
|
|
1137
|
+
as Claude, so the Claude walker applies. The top-level fallback
|
|
1138
|
+
does not include ``prompt_response``, which is why this branch is
|
|
1139
|
+
necessary.
|
|
1140
|
+
|
|
1141
|
+
Sources: <https://www.geminicli.com/docs/hooks/>,
|
|
1142
|
+
<https://www.geminicli.com/docs/hooks/reference/>.
|
|
1143
|
+
"""
|
|
1144
|
+
if event in ("agent_response", "stop"):
|
|
1145
|
+
v = payload.get("prompt_response") or payload.get("promptResponse")
|
|
1146
|
+
if isinstance(v, str) and v.strip():
|
|
1147
|
+
return v.strip()
|
|
1148
|
+
tp = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
1149
|
+
if isinstance(tp, str):
|
|
1150
|
+
txt = _extract_claude_transcript_response(tp)
|
|
1151
|
+
if txt:
|
|
1152
|
+
return txt
|
|
1153
|
+
return ""
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _extract_windsurf_text(
|
|
1157
|
+
payload: dict[str, Any], event: str | None,
|
|
1158
|
+
) -> str:
|
|
1159
|
+
"""Windsurf hook payload extractor (docs-verified, 2026-05).
|
|
1160
|
+
|
|
1161
|
+
Windsurf has two agent-response variants. ``post_cascade_response``
|
|
1162
|
+
(synchronous) nests the response under ``tool_info.response`` as a
|
|
1163
|
+
markdown string; ``post_cascade_response_with_transcript`` carries
|
|
1164
|
+
a ``transcript_path`` to a JSONL file (Claude-format). The
|
|
1165
|
+
``pre_user_prompt`` event keeps the prompt under the top-level
|
|
1166
|
+
``prompt`` (covered by the fallback).
|
|
1167
|
+
|
|
1168
|
+
Sources: <https://docs.windsurf.com/windsurf/cascade/hooks>.
|
|
1169
|
+
"""
|
|
1170
|
+
if event in ("agent_response", "stop"):
|
|
1171
|
+
info = payload.get("tool_info") or payload.get("toolInfo")
|
|
1172
|
+
if isinstance(info, dict):
|
|
1173
|
+
v = info.get("response") or info.get("text")
|
|
1174
|
+
if isinstance(v, str) and v.strip():
|
|
1175
|
+
return v.strip()
|
|
1176
|
+
tp = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
1177
|
+
if isinstance(tp, str):
|
|
1178
|
+
txt = _extract_claude_transcript_response(tp)
|
|
1179
|
+
if txt:
|
|
1180
|
+
return txt
|
|
1181
|
+
return ""
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _extract_hook_text(
|
|
1185
|
+
payload: dict[str, Any],
|
|
1186
|
+
*,
|
|
1187
|
+
platform: str | None = None,
|
|
1188
|
+
event: str | None = None,
|
|
1189
|
+
) -> str:
|
|
840
1190
|
"""Pull a textual snippet out of a platform's hook payload.
|
|
841
1191
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1192
|
+
Platform-aware when ``platform`` is supplied: prefers nested keys
|
|
1193
|
+
that the platform documents (Augment ``conversation.*``, Claude Code
|
|
1194
|
+
``transcript_path`` JSONL, Cursor/Gemini/Windsurf docs-verified
|
|
1195
|
+
branches). Falls back to common top-level keys so legacy callers
|
|
1196
|
+
and simple platforms keep working.
|
|
845
1197
|
"""
|
|
1198
|
+
# Augment Code (with includeConversationData: true) — Stop payloads
|
|
1199
|
+
# arrive nested under "conversation".
|
|
1200
|
+
if platform == "augment":
|
|
1201
|
+
user, agent = _extract_augment_conversation(payload)
|
|
1202
|
+
if event == "user_prompt" and user:
|
|
1203
|
+
return user
|
|
1204
|
+
if event in ("stop", "agent_response") and agent:
|
|
1205
|
+
return agent
|
|
1206
|
+
if agent:
|
|
1207
|
+
return agent
|
|
1208
|
+
if user:
|
|
1209
|
+
return user
|
|
1210
|
+
# Claude Code — Stop payload only has transcript_path; parse JSONL
|
|
1211
|
+
# to recover the last assistant message. Cowork (the Claude desktop
|
|
1212
|
+
# app's local-agent-mode runtime) shares the same payload shape, so
|
|
1213
|
+
# the same extractor applies.
|
|
1214
|
+
if platform in ("claude", "cowork") and event in ("stop", "agent_response"):
|
|
1215
|
+
tp = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
1216
|
+
if isinstance(tp, str):
|
|
1217
|
+
txt = _extract_claude_transcript_response(tp)
|
|
1218
|
+
if txt:
|
|
1219
|
+
return txt
|
|
1220
|
+
if platform == "cursor":
|
|
1221
|
+
txt = _extract_cursor_text(payload, event)
|
|
1222
|
+
if txt:
|
|
1223
|
+
return txt
|
|
1224
|
+
if platform == "cline":
|
|
1225
|
+
txt = _extract_cline_text(payload, event)
|
|
1226
|
+
if txt:
|
|
1227
|
+
return txt
|
|
1228
|
+
if platform == "gemini":
|
|
1229
|
+
txt = _extract_gemini_text(payload, event)
|
|
1230
|
+
if txt:
|
|
1231
|
+
return txt
|
|
1232
|
+
if platform == "windsurf":
|
|
1233
|
+
txt = _extract_windsurf_text(payload, event)
|
|
1234
|
+
if txt:
|
|
1235
|
+
return txt
|
|
846
1236
|
for key in ("prompt", "user_prompt", "first_user_msg", "firstUserMsg",
|
|
847
1237
|
"userMessage", "user_message", "text", "response", "message",
|
|
848
1238
|
"content"):
|
|
@@ -877,24 +1267,39 @@ def _extract_hook_event(payload: dict[str, Any]) -> str:
|
|
|
877
1267
|
return ""
|
|
878
1268
|
|
|
879
1269
|
|
|
1270
|
+
def _extract_session_id(payload: dict[str, Any]) -> str:
|
|
1271
|
+
"""Pull a stable session identifier out of a platform's hook payload.
|
|
1272
|
+
|
|
1273
|
+
Used by hook_dispatch as a fallback first-user-msg source on
|
|
1274
|
+
platforms whose SessionStart payload does not include the user
|
|
1275
|
+
prompt (notably Augment Code).
|
|
1276
|
+
"""
|
|
1277
|
+
for key in ("session_id", "sessionId", "task_id", "taskId",
|
|
1278
|
+
"conversation_id", "conversationId"):
|
|
1279
|
+
v = payload.get(key)
|
|
1280
|
+
if isinstance(v, str) and v.strip():
|
|
1281
|
+
return v.strip()
|
|
1282
|
+
return ""
|
|
1283
|
+
|
|
1284
|
+
|
|
880
1285
|
def hook_dispatch(platform: str, raw_json: str, *,
|
|
881
1286
|
event_override: str | None = None,
|
|
882
1287
|
path: Path | None = None,
|
|
883
1288
|
settings_path: Path | None = None) -> dict[str, Any]:
|
|
884
1289
|
"""Read a platform's stdin JSON, translate to our hook vocabulary, dispatch.
|
|
885
1290
|
|
|
886
|
-
Used by
|
|
887
|
-
projects can wire their
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1291
|
+
Used by ``chat_history.py hook-dispatch --platform <name>`` so
|
|
1292
|
+
consumer projects can wire their per-platform hook config to a
|
|
1293
|
+
single command. The mapping comes from ``PLATFORM_EVENT_MAP``;
|
|
1294
|
+
unmapped events are silently skipped (returned as
|
|
1295
|
+
``skipped_unmapped_event``).
|
|
1296
|
+
|
|
1297
|
+
Schema v4: every dispatch extracts the platform's stable
|
|
1298
|
+
``session_id`` from the payload and forwards it to
|
|
1299
|
+
:func:`hook_append`, where :func:`derive_session_tag` produces the
|
|
1300
|
+
16-char ``s`` tag carried on every body entry. No sidecar, no
|
|
1301
|
+
ownership, no auto-adopt — multi-session coexistence is implicit
|
|
1302
|
+
via the ``s`` field.
|
|
898
1303
|
"""
|
|
899
1304
|
if platform not in PLATFORM_EVENT_MAP:
|
|
900
1305
|
raise ValueError(
|
|
@@ -912,45 +1317,63 @@ def hook_dispatch(platform: str, raw_json: str, *,
|
|
|
912
1317
|
if not isinstance(payload, dict):
|
|
913
1318
|
raise ValueError("stdin JSON must decode to an object")
|
|
914
1319
|
|
|
915
|
-
|
|
1320
|
+
# Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md). When
|
|
1321
|
+
# the dispatcher invoked us, stdin carries {schema_version, platform,
|
|
1322
|
+
# event, payload, …}; pull the platform-native data out of `payload`
|
|
1323
|
+
# and let the envelope's `event` override the per-platform mapping.
|
|
1324
|
+
envelope_event = ""
|
|
1325
|
+
if all(k in payload for k in ("schema_version", "platform", "event", "payload")):
|
|
1326
|
+
envelope_event = (payload.get("native_event") or payload.get("event") or "").strip()
|
|
1327
|
+
inner = payload.get("payload")
|
|
1328
|
+
payload = inner if isinstance(inner, dict) else {}
|
|
1329
|
+
|
|
1330
|
+
raw_event = (event_override or envelope_event or _extract_hook_event(payload) or "").strip()
|
|
916
1331
|
event = PLATFORM_EVENT_MAP[platform].get(raw_event)
|
|
917
1332
|
if not event:
|
|
918
1333
|
return {"action": "skipped_unmapped_event", "platform": platform,
|
|
919
1334
|
"raw_event": raw_event}
|
|
920
1335
|
|
|
921
|
-
text = _extract_hook_text(payload)
|
|
1336
|
+
text = _extract_hook_text(payload, platform=platform, event=event)
|
|
922
1337
|
tool = _extract_hook_tool(payload)
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
#
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1338
|
+
session_id = _extract_session_id(payload)
|
|
1339
|
+
|
|
1340
|
+
# Augment dual-emit: with includeConversationData: true the Stop
|
|
1341
|
+
# payload carries both the user prompt and the agent response in one
|
|
1342
|
+
# call (Augment has no UserPromptSubmit equivalent). Synthesize a
|
|
1343
|
+
# user_prompt append before the stop append so both sides land in
|
|
1344
|
+
# history under the active cadence.
|
|
1345
|
+
augment_user_prompt = ""
|
|
1346
|
+
if platform == "augment" and event == "stop":
|
|
1347
|
+
u, _a = _extract_augment_conversation(payload)
|
|
1348
|
+
augment_user_prompt = u
|
|
1349
|
+
|
|
1350
|
+
hook_payload: dict[str, Any] = {
|
|
1351
|
+
"source": f"hook:{platform}:{raw_event}",
|
|
1352
|
+
"agent": platform,
|
|
1353
|
+
}
|
|
929
1354
|
if text and event != "session_start":
|
|
930
1355
|
hook_payload["text"] = text
|
|
931
1356
|
if tool:
|
|
932
1357
|
hook_payload["tool"] = tool
|
|
933
1358
|
|
|
934
|
-
|
|
1359
|
+
if augment_user_prompt:
|
|
1360
|
+
hook_append(
|
|
1361
|
+
"user_prompt",
|
|
1362
|
+
session_id=session_id,
|
|
1363
|
+
payload={
|
|
1364
|
+
"text": augment_user_prompt,
|
|
1365
|
+
"source": f"hook:{platform}:{raw_event}:user",
|
|
1366
|
+
"agent": platform,
|
|
1367
|
+
},
|
|
1368
|
+
path=path, settings_path=settings_path,
|
|
1369
|
+
)
|
|
935
1370
|
|
|
936
|
-
|
|
937
|
-
return hook_append("session_start", first_user_msg=fum,
|
|
938
|
-
path=path, settings_path=settings_path)
|
|
939
|
-
|
|
940
|
-
# Bootstrap: the first non-session_start event from a platform whose
|
|
941
|
-
# SessionStart did not carry the prompt (e.g. Claude Code) needs an
|
|
942
|
-
# implicit init so ownership and the sidecar exist before append.
|
|
943
|
-
side = read_sidecar(p)
|
|
944
|
-
if side is None and fum:
|
|
945
|
-
hook_append("session_start", first_user_msg=fum,
|
|
946
|
-
path=path, settings_path=settings_path)
|
|
947
|
-
|
|
948
|
-
return hook_append(event, first_user_msg=fum, payload=hook_payload,
|
|
1371
|
+
return hook_append(event, session_id=session_id, payload=hook_payload,
|
|
949
1372
|
path=path, settings_path=settings_path)
|
|
950
1373
|
|
|
951
1374
|
|
|
952
1375
|
def _cmd_init(args) -> int:
|
|
953
|
-
h = init(
|
|
1376
|
+
h = init(freq=args.freq)
|
|
954
1377
|
print(json.dumps(h, ensure_ascii=False))
|
|
955
1378
|
return 0
|
|
956
1379
|
|
|
@@ -972,7 +1395,7 @@ def _cmd_hook_append(args) -> int:
|
|
|
972
1395
|
try:
|
|
973
1396
|
result = hook_append(
|
|
974
1397
|
args.event,
|
|
975
|
-
|
|
1398
|
+
session_id=args.session_id,
|
|
976
1399
|
payload=payload,
|
|
977
1400
|
settings_path=settings_path,
|
|
978
1401
|
)
|
|
@@ -980,8 +1403,6 @@ def _cmd_hook_append(args) -> int:
|
|
|
980
1403
|
print(f"error: {exc}", file=sys.stderr)
|
|
981
1404
|
return EXIT_BAD_ARGS
|
|
982
1405
|
print(json.dumps(result, ensure_ascii=False))
|
|
983
|
-
if result.get("action") == "ownership_refused":
|
|
984
|
-
return EXIT_OWNERSHIP_REFUSED
|
|
985
1406
|
return EXIT_OK
|
|
986
1407
|
|
|
987
1408
|
|
|
@@ -999,8 +1420,6 @@ def _cmd_hook_dispatch(args) -> int:
|
|
|
999
1420
|
print(f"error: {exc}", file=sys.stderr)
|
|
1000
1421
|
return EXIT_BAD_ARGS
|
|
1001
1422
|
print(json.dumps(result, ensure_ascii=False))
|
|
1002
|
-
if result.get("action") == "ownership_refused":
|
|
1003
|
-
return EXIT_OWNERSHIP_REFUSED
|
|
1004
1423
|
return EXIT_OK
|
|
1005
1424
|
|
|
1006
1425
|
|
|
@@ -1011,17 +1430,8 @@ def _cmd_append(args) -> int:
|
|
|
1011
1430
|
print("error: --type or a 't' key in --json is required",
|
|
1012
1431
|
file=sys.stderr)
|
|
1013
1432
|
return EXIT_BAD_ARGS
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
except OwnershipError as exc:
|
|
1017
|
-
print(
|
|
1018
|
-
f"error: append refused — state={exc.state}; "
|
|
1019
|
-
f"header_fp={exc.header_fp[:8]} current_fp={exc.current_fp[:8]}. "
|
|
1020
|
-
f"Run `chat_history.py turn-check --first-user-msg \"...\"` "
|
|
1021
|
-
f"and resolve ownership before retrying.",
|
|
1022
|
-
file=sys.stderr,
|
|
1023
|
-
)
|
|
1024
|
-
return EXIT_OWNERSHIP_REFUSED
|
|
1433
|
+
session = derive_session_tag(args.session_id) if args.session_id else None
|
|
1434
|
+
append(entry, session=session)
|
|
1025
1435
|
return EXIT_OK
|
|
1026
1436
|
|
|
1027
1437
|
|
|
@@ -1030,91 +1440,6 @@ def _cmd_status(_args) -> int:
|
|
|
1030
1440
|
return 0
|
|
1031
1441
|
|
|
1032
1442
|
|
|
1033
|
-
def _cmd_check(args) -> int:
|
|
1034
|
-
print(check_ownership(args.first_user_msg))
|
|
1035
|
-
return 0
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
def _cmd_state(args) -> int:
|
|
1039
|
-
print(ownership_state(args.first_user_msg))
|
|
1040
|
-
return 0
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
def _format_turn_check_stdout(result: dict[str, Any]) -> str:
|
|
1044
|
-
"""Render turn_check() result as a single key=value line for shell parsing."""
|
|
1045
|
-
state = result["state"]
|
|
1046
|
-
parts = [f"state={state}"]
|
|
1047
|
-
if "entries" in result:
|
|
1048
|
-
parts.append(f"entries={result['entries']}")
|
|
1049
|
-
if state in {"foreign", "returning"}:
|
|
1050
|
-
parts.append(f"header_fp={str(result.get('header_fp', ''))[:8]}")
|
|
1051
|
-
parts.append(f"current_fp={str(result.get('current_fp', ''))[:8]}")
|
|
1052
|
-
preview = str(result.get("preview", "")).replace('"', "'")
|
|
1053
|
-
if preview:
|
|
1054
|
-
parts.append(f'preview="{preview[:80]}"')
|
|
1055
|
-
return " ".join(parts)
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
def _turn_check_action_hint(state: str) -> str:
|
|
1059
|
-
"""Stderr hint telling the agent which prompt to render."""
|
|
1060
|
-
if state == "ok":
|
|
1061
|
-
return ""
|
|
1062
|
-
if state == "disabled":
|
|
1063
|
-
return ""
|
|
1064
|
-
if state == "missing":
|
|
1065
|
-
return ("ACTION REQUIRED: state=missing — run "
|
|
1066
|
-
"`chat_history.py init --first-user-msg \"...\" "
|
|
1067
|
-
"--freq <frequency-from-settings>` before any other reply.")
|
|
1068
|
-
if state == "foreign":
|
|
1069
|
-
return ("ACTION REQUIRED: state=foreign — render the Foreign-Prompt "
|
|
1070
|
-
"from the chat-history rule (3 numbered options: Resume / "
|
|
1071
|
-
"New start / Ignore) before any other reply. Do not append "
|
|
1072
|
-
"to this file until the user picks.")
|
|
1073
|
-
if state == "returning":
|
|
1074
|
-
return ("ACTION REQUIRED: state=returning — render the "
|
|
1075
|
-
"Returning-Prompt from the chat-history rule (3 numbered "
|
|
1076
|
-
"options: Merge / Replace / Continue) before any other "
|
|
1077
|
-
"reply. Do not append to this file until the user picks.")
|
|
1078
|
-
return f"ACTION REQUIRED: unknown state={state}"
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
def _cmd_turn_check(args) -> int:
|
|
1082
|
-
settings_path = Path(args.settings) if args.settings else None
|
|
1083
|
-
result = turn_check(args.first_user_msg, settings_path=settings_path)
|
|
1084
|
-
print(_format_turn_check_stdout(result))
|
|
1085
|
-
hint = _turn_check_action_hint(result["state"])
|
|
1086
|
-
if hint:
|
|
1087
|
-
print(hint, file=sys.stderr)
|
|
1088
|
-
return int(result["exit"])
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
def _cmd_heartbeat(args) -> int:
|
|
1092
|
-
settings_path = Path(args.settings) if args.settings else None
|
|
1093
|
-
result = heartbeat(args.first_user_msg, settings_path=settings_path)
|
|
1094
|
-
if args.json:
|
|
1095
|
-
# JSON consumers want the full record regardless of mode.
|
|
1096
|
-
print(json.dumps(result, ensure_ascii=False))
|
|
1097
|
-
return EXIT_OK
|
|
1098
|
-
mode = _read_chat_history_heartbeat_mode(
|
|
1099
|
-
settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
1100
|
-
)
|
|
1101
|
-
state = str(result.get("state", ""))
|
|
1102
|
-
# off → never print. hybrid → only on drift states.
|
|
1103
|
-
# on → always (current behavior).
|
|
1104
|
-
if mode == "off":
|
|
1105
|
-
return EXIT_OK
|
|
1106
|
-
if mode == "hybrid" and state not in DRIFT_STATES:
|
|
1107
|
-
return EXIT_OK
|
|
1108
|
-
print(result["marker"])
|
|
1109
|
-
return EXIT_OK
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
def _cmd_adopt(args) -> int:
|
|
1113
|
-
h = adopt(args.first_user_msg)
|
|
1114
|
-
print(json.dumps(h, ensure_ascii=False))
|
|
1115
|
-
return 0
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
1443
|
def _load_entries_arg(args) -> list[dict[str, Any]]:
|
|
1119
1444
|
if getattr(args, "entries_stdin", False):
|
|
1120
1445
|
raw = sys.stdin.read()
|
|
@@ -1131,11 +1456,23 @@ def _load_entries_arg(args) -> list[dict[str, Any]]:
|
|
|
1131
1456
|
|
|
1132
1457
|
def _cmd_reset(args) -> int:
|
|
1133
1458
|
entries = _load_entries_arg(args)
|
|
1134
|
-
h = reset_with_entries(
|
|
1459
|
+
h = reset_with_entries(entries, freq=args.freq)
|
|
1135
1460
|
print(json.dumps(h, ensure_ascii=False))
|
|
1136
1461
|
return 0
|
|
1137
1462
|
|
|
1138
1463
|
|
|
1464
|
+
def _cmd_prune_sessions(args) -> int:
|
|
1465
|
+
settings_path = Path(args.settings) if args.settings else None
|
|
1466
|
+
if args.max_sessions is not None:
|
|
1467
|
+
max_n = max(1, int(args.max_sessions))
|
|
1468
|
+
else:
|
|
1469
|
+
sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
|
|
1470
|
+
max_n = _read_chat_history_max_sessions(sp)
|
|
1471
|
+
result = prune_sessions(max_n)
|
|
1472
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
1473
|
+
return EXIT_OK
|
|
1474
|
+
|
|
1475
|
+
|
|
1139
1476
|
def _cmd_prepend(args) -> int:
|
|
1140
1477
|
entries = _load_entries_arg(args)
|
|
1141
1478
|
n = prepend_entries(entries)
|
|
@@ -1150,11 +1487,46 @@ def _cmd_clear(_args) -> int:
|
|
|
1150
1487
|
|
|
1151
1488
|
def _cmd_read(args) -> int:
|
|
1152
1489
|
last = None if args.all else args.last
|
|
1153
|
-
|
|
1490
|
+
if args.all:
|
|
1491
|
+
entries = read_entries(last=last, session=None)
|
|
1492
|
+
elif args.session is not None:
|
|
1493
|
+
entries = read_entries(last=last, session=args.session)
|
|
1494
|
+
else:
|
|
1495
|
+
entries = read_entries_for_current(last=last)
|
|
1154
1496
|
print(json.dumps(entries, ensure_ascii=False, indent=2))
|
|
1155
1497
|
return 0
|
|
1156
1498
|
|
|
1157
1499
|
|
|
1500
|
+
def _cmd_sessions(args) -> int:
|
|
1501
|
+
sessions = list_sessions(summary=args.summary)
|
|
1502
|
+
if not args.include_empty:
|
|
1503
|
+
sessions = [s for s in sessions if s["count"] > 0]
|
|
1504
|
+
sessions = sessions[: args.limit]
|
|
1505
|
+
if args.json:
|
|
1506
|
+
print(json.dumps(sessions, ensure_ascii=False, indent=2))
|
|
1507
|
+
return 0
|
|
1508
|
+
if not sessions:
|
|
1509
|
+
print("(no sessions)")
|
|
1510
|
+
return 0
|
|
1511
|
+
last_col = "SUMMARY" if args.summary else "PREVIEW"
|
|
1512
|
+
rows = [("ID", "COUNT", "LAST_TS", last_col)]
|
|
1513
|
+
for s in sessions:
|
|
1514
|
+
last_val = s.get("summary") if args.summary else s.get("preview")
|
|
1515
|
+
rows.append((
|
|
1516
|
+
s["id"],
|
|
1517
|
+
str(s["count"]),
|
|
1518
|
+
s["last_ts"] or "-",
|
|
1519
|
+
last_val or "-",
|
|
1520
|
+
))
|
|
1521
|
+
widths = [max(len(r[i]) for r in rows) for i in range(4)]
|
|
1522
|
+
for i, r in enumerate(rows):
|
|
1523
|
+
line = " ".join(r[j].ljust(widths[j]) for j in range(4))
|
|
1524
|
+
print(line)
|
|
1525
|
+
if i == 0:
|
|
1526
|
+
print(" ".join("-" * widths[j] for j in range(4)))
|
|
1527
|
+
return 0
|
|
1528
|
+
|
|
1529
|
+
|
|
1158
1530
|
def _cmd_rotate(args) -> int:
|
|
1159
1531
|
result = overflow_handle(args.max_kb, mode=args.mode)
|
|
1160
1532
|
print(json.dumps(result, ensure_ascii=False))
|
|
@@ -1165,61 +1537,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1165
1537
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
1166
1538
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1167
1539
|
p_init = sub.add_parser("init")
|
|
1168
|
-
p_init.add_argument("--first-user-msg", required=True)
|
|
1169
1540
|
p_init.add_argument("--freq", default="per_phase", choices=sorted(VALID_FREQS))
|
|
1170
1541
|
p_init.set_defaults(func=_cmd_init)
|
|
1171
1542
|
p_app = sub.add_parser("append")
|
|
1172
1543
|
p_app.add_argument("--type", help="entry type (t field)")
|
|
1173
1544
|
p_app.add_argument("--json", help="JSON object with entry fields")
|
|
1174
1545
|
p_app.add_argument(
|
|
1175
|
-
"--
|
|
1546
|
+
"--session-id",
|
|
1176
1547
|
default=None,
|
|
1177
|
-
help=("
|
|
1178
|
-
|
|
1548
|
+
help=("platform session id; hashed to derive the body 's' tag. "
|
|
1549
|
+
"Omit to write entries with s=<unknown>."),
|
|
1179
1550
|
)
|
|
1180
1551
|
p_app.set_defaults(func=_cmd_append)
|
|
1181
1552
|
sub.add_parser("status").set_defaults(func=_cmd_status)
|
|
1182
|
-
p_chk = sub.add_parser("check")
|
|
1183
|
-
p_chk.add_argument("--first-user-msg", required=True)
|
|
1184
|
-
p_chk.set_defaults(func=_cmd_check)
|
|
1185
|
-
p_state = sub.add_parser("state")
|
|
1186
|
-
p_state.add_argument("--first-user-msg", required=True)
|
|
1187
|
-
p_state.set_defaults(func=_cmd_state)
|
|
1188
|
-
p_tc = sub.add_parser(
|
|
1189
|
-
"turn-check",
|
|
1190
|
-
help=("turn-start ownership gate; exit 0=ok/disabled, "
|
|
1191
|
-
f"{EXIT_MISSING}=missing, {EXIT_FOREIGN}=foreign, "
|
|
1192
|
-
f"{EXIT_RETURNING}=returning"),
|
|
1193
|
-
)
|
|
1194
|
-
p_tc.add_argument("--first-user-msg", required=True)
|
|
1195
|
-
p_tc.add_argument(
|
|
1196
|
-
"--settings",
|
|
1197
|
-
default=None,
|
|
1198
|
-
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1199
|
-
)
|
|
1200
|
-
p_tc.set_defaults(func=_cmd_turn_check)
|
|
1201
|
-
p_hb = sub.add_parser(
|
|
1202
|
-
"heartbeat",
|
|
1203
|
-
help=("emit the in-band reply marker; always exit 0. "
|
|
1204
|
-
"Agent must include the stdout line verbatim in every reply."),
|
|
1205
|
-
)
|
|
1206
|
-
p_hb.add_argument("--first-user-msg", required=True)
|
|
1207
|
-
p_hb.add_argument(
|
|
1208
|
-
"--settings",
|
|
1209
|
-
default=None,
|
|
1210
|
-
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1211
|
-
)
|
|
1212
|
-
p_hb.add_argument(
|
|
1213
|
-
"--json",
|
|
1214
|
-
action="store_true",
|
|
1215
|
-
help="emit the full result dict instead of just the marker",
|
|
1216
|
-
)
|
|
1217
|
-
p_hb.set_defaults(func=_cmd_heartbeat)
|
|
1218
|
-
p_ado = sub.add_parser("adopt")
|
|
1219
|
-
p_ado.add_argument("--first-user-msg", required=True)
|
|
1220
|
-
p_ado.set_defaults(func=_cmd_adopt)
|
|
1221
1553
|
p_reset = sub.add_parser("reset")
|
|
1222
|
-
p_reset.add_argument("--first-user-msg", required=True)
|
|
1223
1554
|
p_reset.add_argument("--freq", default="per_phase",
|
|
1224
1555
|
choices=sorted(VALID_FREQS))
|
|
1225
1556
|
g_r = p_reset.add_mutually_exclusive_group(required=True)
|
|
@@ -1228,6 +1559,23 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1228
1559
|
g_r.add_argument("--entries-stdin", action="store_true",
|
|
1229
1560
|
help="read JSON array from stdin")
|
|
1230
1561
|
p_reset.set_defaults(func=_cmd_reset)
|
|
1562
|
+
p_prune = sub.add_parser(
|
|
1563
|
+
"prune-sessions",
|
|
1564
|
+
help=("keep only the N most-recent sessions in the body; "
|
|
1565
|
+
"N defaults to chat_history.max_sessions"),
|
|
1566
|
+
)
|
|
1567
|
+
p_prune.add_argument(
|
|
1568
|
+
"--max-sessions",
|
|
1569
|
+
type=int,
|
|
1570
|
+
default=None,
|
|
1571
|
+
help=f"max distinct sessions to keep (default: settings or {DEFAULT_MAX_SESSIONS})",
|
|
1572
|
+
)
|
|
1573
|
+
p_prune.add_argument(
|
|
1574
|
+
"--settings",
|
|
1575
|
+
default=None,
|
|
1576
|
+
help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
|
|
1577
|
+
)
|
|
1578
|
+
p_prune.set_defaults(func=_cmd_prune_sessions)
|
|
1231
1579
|
p_prep = sub.add_parser("prepend")
|
|
1232
1580
|
g_p = p_prep.add_mutually_exclusive_group(required=True)
|
|
1233
1581
|
g_p.add_argument("--entries-json",
|
|
@@ -1241,28 +1589,46 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1241
1589
|
grp.add_argument("--last", type=int, default=5,
|
|
1242
1590
|
help="return the trailing N entries (default: 5)")
|
|
1243
1591
|
grp.add_argument("--all", action="store_true",
|
|
1244
|
-
help="return all entries")
|
|
1592
|
+
help="return all entries (across all sessions)")
|
|
1593
|
+
p_read.add_argument(
|
|
1594
|
+
"--session", default=None,
|
|
1595
|
+
help=("filter to entries with this session tag "
|
|
1596
|
+
"(16-char sha256(session_id), '<legacy>', or '<unknown>'); "
|
|
1597
|
+
"defaults to the most recent session"),
|
|
1598
|
+
)
|
|
1245
1599
|
p_read.set_defaults(func=_cmd_read)
|
|
1600
|
+
p_sess = sub.add_parser("sessions")
|
|
1601
|
+
p_sess.add_argument("--limit", type=int, default=20,
|
|
1602
|
+
help="max non-empty sessions to print (default: 20)")
|
|
1603
|
+
p_sess.add_argument("--include-empty", action="store_true",
|
|
1604
|
+
help="include sessions with zero body entries")
|
|
1605
|
+
p_sess.add_argument("--json", action="store_true",
|
|
1606
|
+
help="emit JSON instead of a human-readable table")
|
|
1607
|
+
p_sess.add_argument("--summary", action="store_true",
|
|
1608
|
+
help=("include a head-5 + tail-5 sampled summary "
|
|
1609
|
+
"(max 10 entries) per session — token-cheap "
|
|
1610
|
+
"alternative to the bare preview"))
|
|
1611
|
+
p_sess.set_defaults(func=_cmd_sessions)
|
|
1246
1612
|
p_rot = sub.add_parser("rotate")
|
|
1247
1613
|
p_rot.add_argument("--max-kb", type=int, default=256)
|
|
1248
1614
|
p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
|
|
1249
1615
|
p_rot.set_defaults(func=_cmd_rotate)
|
|
1250
1616
|
p_hook = sub.add_parser(
|
|
1251
1617
|
"hook-append",
|
|
1252
|
-
help=("platform-hook entry point —
|
|
1253
|
-
"
|
|
1618
|
+
help=("platform-hook entry point — stateless append per session; "
|
|
1619
|
+
"derives the body 's' tag from --session-id"),
|
|
1254
1620
|
)
|
|
1255
1621
|
p_hook.add_argument(
|
|
1256
1622
|
"--event",
|
|
1257
1623
|
required=True,
|
|
1258
1624
|
choices=sorted(VALID_HOOK_EVENTS),
|
|
1259
|
-
help="hook event name
|
|
1625
|
+
help="hook event name",
|
|
1260
1626
|
)
|
|
1261
1627
|
p_hook.add_argument(
|
|
1262
|
-
"--
|
|
1628
|
+
"--session-id",
|
|
1263
1629
|
default=None,
|
|
1264
|
-
help=("
|
|
1265
|
-
"
|
|
1630
|
+
help=("platform session id; hashed to derive the body 's' tag. "
|
|
1631
|
+
"Omit to write entries with s=<unknown>."),
|
|
1266
1632
|
)
|
|
1267
1633
|
p_hook.add_argument(
|
|
1268
1634
|
"--payload",
|