@event4u/agent-config 1.19.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.
Files changed (88) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +4 -7
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/sync-gitignore.md +1 -1
  9. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  10. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  11. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  12. package/.agent-src/rules/direct-answers.md +10 -2
  13. package/.agent-src/rules/language-and-tone.md +37 -6
  14. package/.agent-src/rules/no-attribution-footers.md +48 -0
  15. package/.agent-src/rules/no-roadmap-references.md +1 -1
  16. package/.agent-src/rules/skill-quality.md +49 -0
  17. package/.agent-src/rules/user-interaction.md +21 -5
  18. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  19. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  20. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  21. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  22. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  23. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  24. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  25. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  26. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  27. package/.agent-src/templates/agent-settings.md +5 -26
  28. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +7 -5
  29. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +0 -4
  30. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +0 -4
  31. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  32. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  33. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  34. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +2 -3
  35. package/.agent-src/templates/skill.md +30 -1
  36. package/.claude-plugin/marketplace.json +8 -4
  37. package/AGENTS.md +44 -3
  38. package/CHANGELOG.md +111 -0
  39. package/README.md +6 -6
  40. package/config/agent-settings.template.yml +19 -13
  41. package/config/gitignore-block.txt +4 -4
  42. package/docs/architecture.md +3 -3
  43. package/docs/catalog.md +14 -12
  44. package/docs/contracts/adr-chat-history-split.md +10 -1
  45. package/docs/contracts/command-clusters.md +1 -1
  46. package/docs/contracts/cross-wing-handoff.md +133 -0
  47. package/docs/contracts/file-ownership-matrix.json +341 -126
  48. package/docs/contracts/hook-architecture-v1.md +8 -1
  49. package/docs/contracts/memory-visibility-v1.md +8 -24
  50. package/docs/customization.md +1 -1
  51. package/docs/getting-started.md +21 -29
  52. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  53. package/docs/hook-payload-capture.md +221 -0
  54. package/docs/migrations/commands-1.15.0.md +17 -12
  55. package/docs/skills-catalog.md +5 -4
  56. package/llms.txt +4 -3
  57. package/package.json +1 -1
  58. package/scripts/agent-config +1 -1
  59. package/scripts/ai_council/_default_prices.py +4 -4
  60. package/scripts/ai_council/clients.py +1 -1
  61. package/scripts/ai_council/modes.py +3 -4
  62. package/scripts/ai_council/pricing.py +10 -9
  63. package/scripts/build_rule_trigger_matrix.py +1 -9
  64. package/scripts/chat_history.py +952 -596
  65. package/scripts/check_references.py +12 -2
  66. package/scripts/council_cli.py +54 -4
  67. package/scripts/hook_manifest.yaml +33 -0
  68. package/scripts/hooks/augment-chat-history.sh +10 -0
  69. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  70. package/scripts/hooks/dispatch_hook.py +35 -0
  71. package/scripts/hooks_status.py +12 -1
  72. package/scripts/install-hooks.sh +2 -2
  73. package/scripts/install.sh +37 -0
  74. package/scripts/lint_handoffs.py +214 -0
  75. package/scripts/lint_hook_manifest.py +2 -1
  76. package/scripts/redact_hook_capture.py +148 -0
  77. package/scripts/schemas/skill.schema.json +5 -0
  78. package/scripts/skill_linter.py +163 -1
  79. package/scripts/update_prices.py +3 -3
  80. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  81. package/.agent-src/commands/chat-history/clear.md +0 -103
  82. package/.agent-src/commands/chat-history/resume.md +0 -183
  83. package/.agent-src/rules/chat-history-cadence.md +0 -143
  84. package/.agent-src/rules/chat-history-ownership.md +0 -124
  85. package/.agent-src/rules/chat-history-visibility.md +0 -97
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  88. package/scripts/check_phase_coupling.py +0 -148
@@ -1,29 +1,29 @@
1
1
  #!/usr/bin/env python3
2
2
  """Persistent chat-history log for crash recovery.
3
3
 
4
- Maintains `.agent-chat-history` in the project root — a JSONL file whose
5
- first line is a header (session id, fingerprint, frequency mode) and
6
- whose remaining lines are append-only entries (user messages, phases,
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
- Ownership is established via SHA-256 of the first user message in the
10
- conversation, stored in the header. Agents read this on every turn to
11
- detect whether the file belongs to the current conversation.
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 `.agent-chat-history` in CWD and can be overridden
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 --first-user-msg "..." [--freq per_phase]
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 heartbeat --first-user-msg "..."
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 = ".agent-chat-history"
45
+ DEFAULT_FILE = "agents/.agent-chat-history"
45
46
  DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
46
- SCHEMA_VERSION = 2
47
- FORMER_FPS_CAP = 10
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(first_user_msg: str) -> str:
83
- """SHA-256 of the normalized first user message (whitespace collapsed)."""
84
- normalized = _WS_RE.sub(" ", first_user_msg or "").strip()
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. Migrates v1 headers in memory (adds `former_fps: []`).
204
+ """Read the header.
95
205
 
96
- The on-disk file is not rewritten by this read; migration is lazy and
97
- happens on the next write (init/adopt/reset).
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(first_user_msg: str, freq: str,
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(first_user_msg: str, freq: str = "per_phase", *,
236
+ def init(freq: str = "per_phase", *,
133
237
  path: Path | None = None) -> dict[str, Any]:
134
- """Overwrite the file with a fresh header for a new session."""
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(first_user_msg, freq)
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
- first_user_msg: str | None = None) -> None:
291
+ session: str | None = None) -> None:
147
292
  """Append one entry. Entry must be a dict; `ts` is auto-filled.
148
293
 
149
- When `first_user_msg` is provided, the call validates ownership
150
- before writing: only `state == match` proceeds. Any other state
151
- (`foreign`, `returning`, `missing`) raises `OwnershipError`. This
152
- is the second line of defense against silent writes to a foreign
153
- session's file. Existing callers without `first_user_msg` keep the
154
- legacy unguarded behavior for back-compat.
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 check_ownership(first_user_msg: str, *,
176
- path: Path | None = None) -> str:
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
- Kept for backward compatibility. Prefer `ownership_state()` for the
180
- 4-state view that distinguishes foreign from returning sessions.
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
- header = read_header(path)
183
- if not header:
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
- with p.open(encoding="utf-8") as fh:
240
- lines = fh.readlines()
241
- lines[0] = json.dumps(header, ensure_ascii=False) + "\n"
242
- tmp = p.with_suffix(p.suffix + ".tmp")
243
- tmp.write_text("".join(lines), encoding="utf-8")
244
- tmp.replace(p)
245
- return header
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(first_user_msg: str,
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 whatever
270
- is on disk. If `former_fps` is None and a header exists, the old fp is
271
- preserved via `_push_former_fp` so the returning/foreign state logic
272
- still works on later switches.
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
- new_fp = fingerprint(first_user_msg)
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
- tmp = p.with_suffix(p.suffix + ".tmp")
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
- tmp = p.with_suffix(p.suffix + ".tmp")
319
- tmp.write_text(header_line + "".join(new_lines) + "".join(body),
320
- encoding="utf-8")
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) -> list[dict[str, Any]]:
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 sidecar_path(path: Path | None = None) -> Path:
506
- """Return the path to the session sidecar (.agent-chat-history.session).
714
+ def _read_chat_history_max_sessions(settings_path: Path) -> int:
715
+ """Read chat_history.max_sessions from .agent-settings.yml.
507
716
 
508
- Sidecar carries the first-user-msg for the active session so hook
509
- invocations after `session_start` don't need the agent to pass it
510
- on every call. Lives next to the JSONL file.
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 "hybrid"
722
+ return DEFAULT_MAX_SESSIONS
558
723
  try:
559
724
  import yaml # type: ignore[import-untyped]
560
725
  except ImportError:
561
- return "hybrid"
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 "hybrid"
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 "hybrid"
570
- raw = section.get("heartbeat", "hybrid")
571
- # YAML 1.1 (PyYAML default) booleanizes bare on/off to True/False.
572
- # Coerce back so users can write `heartbeat: on` without quoting.
573
- if raw is True:
574
- return "on"
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 _last_entry_age_seconds(path: Path) -> int | None:
636
- """Return age of the latest non-header entry in seconds, or None.
742
+ def _read_text_limits(settings_path: Path) -> dict[str, int]:
743
+ """Read chat_history.text_limits from .agent-settings.yml.
637
744
 
638
- Reads the file once, takes the last non-empty line, parses its `ts`
639
- field. Tolerant of malformed lines and missing timestamps — returns
640
- None instead of raising. Used by `heartbeat()` to surface stale
641
- appends in the in-band marker.
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
- if not path.is_file():
644
- return None
645
- last_line: str | None = None
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
- obj = json.loads(last_line)
658
- except json.JSONDecodeError:
659
- return None
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
- parsed = dt.datetime.fromisoformat(ts)
667
- except ValueError:
668
- return None
669
- if parsed.tzinfo is None:
670
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
671
- now = dt.datetime.now(dt.timezone.utc)
672
- return int((now - parsed).total_seconds())
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
- The marker is a single-line string the agent must include verbatim
680
- at the end of every reply. Fields surface state, entry count,
681
- cadence, and age of the last entry so a stale gap (no append
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
- Always returns exit-equivalent 0; this is observability, not a
686
- gate. Ownership refusal lives in `append`, the turn-start gate
687
- lives in `turn_check`. `heartbeat` only reports.
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
- sp = settings_path or Path(DEFAULT_SETTINGS_FILE)
690
- if not _read_chat_history_enabled(sp):
691
- return {"state": "disabled",
692
- "marker": "📒 chat-history: disabled"}
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
- state = ownership_state(first_user_msg, path=p)
695
- st = status(path=p)
696
- entries = int(st.get("entries", 0)) if st.get("exists") else 0
697
- header = st.get("header") or {}
698
- freq = str(header.get("freq", "?")) if header else "?"
699
- if state == "match":
700
- age = _last_entry_age_seconds(p)
701
- age_str = _format_age(age) if age is not None else "no entries"
702
- marker = (f"📒 chat-history: ok · {entries} entries · "
703
- f"{freq} · last {age_str}")
704
- return {"state": "ok", "entries": entries, "freq": freq,
705
- "last_age_seconds": age, "marker": marker}
706
- if state == "missing":
707
- return {"state": "missing",
708
- "marker": "📒 chat-history: missing run init"}
709
- if state == "foreign":
710
- return {"state": "foreign", "entries": entries,
711
- "marker": (f"📒 chat-history: foreign · {entries} "
712
- f"entries on file — render Foreign-Prompt")}
713
- return {"state": "returning", "entries": entries,
714
- "marker": (f"📒 chat-history: returning · {entries} "
715
- f"entries on file — render Returning-Prompt")}
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
- tmp = p.with_suffix(p.suffix + ".tmp")
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
- first_user_msg: str | None = None,
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 — wraps init/append/sidecar.
768
-
769
- Designed for `SessionStart`, `UserPromptSubmit`, `PostToolUse`,
770
- `Stop`, `SessionEnd` style hooks across platforms. Stateless: every
771
- invocation reads the sidecar for the active session's first-user-msg.
772
- The very first call (`event == "session_start"`) writes the sidecar
773
- and initializes the JSONL header if missing.
774
-
775
- Cadence-aware: events that don't match `chat_history.frequency`
776
- are silently skipped. `enabled: false` short-circuits to a noop.
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 (missing sidecar, cadence skip,
780
- disabled) — these surface as `action` values so hooks can choose
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
- if event == "session_start":
792
- if not first_user_msg:
793
- return {"action": "skipped_no_first_user_msg", "event": event}
794
- write_sidecar(first_user_msg, path=p)
795
- if not p.is_file() or read_header(p) is None:
796
- freq = _read_chat_history_frequency(sp)
797
- init(first_user_msg, freq=freq, path=p)
798
- return {"action": "initialized", "event": event,
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
- # Control plane only — touch sidecar's last-seen but do not append.
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, "frequency": freq}
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", "")).strip()
995
+ text = str(payload.get("text", ""))
820
996
  if text:
821
- entry["text"] = _preview(text, 200)
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
- append(entry, path=p, first_user_msg=fum)
831
- except OwnershipError as exc:
832
- return {"action": "ownership_refused", "event": event,
833
- "state": exc.state,
834
- "header_fp": exc.header_fp[:8],
835
- "current_fp": exc.current_fp[:8]}
836
- return {"action": "appended", "event": event, "type": entry_type}
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
- def _extract_hook_text(payload: dict[str, Any]) -> str:
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
- Tries common field names across Claude Code, Augment Code, Cursor,
843
- Cline, Windsurf, and Gemini CLI. Returns the first non-empty string
844
- found, stripped; empty string when nothing usable is present.
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 `chat_history.py hook-dispatch --platform <name>` so consumer
887
- projects can wire their `.claude/settings.json` / `.augment/settings.json`
888
- / `.cursor/hooks.json` etc. to a single command. The mapping comes from
889
- PLATFORM_EVENT_MAP; unmapped events are silently skipped (returned as
890
- `skipped_unmapped_event` so the caller can decide fail-open vs
891
- fail-closed).
892
-
893
- Bootstrap: when the platform fires the very first non-`session_start`
894
- event (e.g. `UserPromptSubmit`) and no sidecar exists yet, the
895
- dispatcher synthesizes a `session_start` first using the prompt as the
896
- `first_user_msg`. This handles platforms whose `SessionStart` payload
897
- does not carry the prompt itself.
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(
@@ -928,39 +1333,47 @@ def hook_dispatch(platform: str, raw_json: str, *,
928
1333
  return {"action": "skipped_unmapped_event", "platform": platform,
929
1334
  "raw_event": raw_event}
930
1335
 
931
- text = _extract_hook_text(payload)
1336
+ text = _extract_hook_text(payload, platform=platform, event=event)
932
1337
  tool = _extract_hook_tool(payload)
933
- # The user's first message is what we hash for ownership. We can only
934
- # extract it from prompt-bearing events; for stop / tool_use / *_end
935
- # the sidecar must already exist.
936
- fum = text if event in {"session_start", "user_prompt"} else None
937
-
938
- hook_payload: dict[str, Any] = {"source": f"hook:{platform}:{raw_event}"}
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
+ }
939
1354
  if text and event != "session_start":
940
1355
  hook_payload["text"] = text
941
1356
  if tool:
942
1357
  hook_payload["tool"] = tool
943
1358
 
944
- p = path or file_path()
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
+ )
945
1370
 
946
- if event == "session_start":
947
- return hook_append("session_start", first_user_msg=fum,
948
- path=path, settings_path=settings_path)
949
-
950
- # Bootstrap: the first non-session_start event from a platform whose
951
- # SessionStart did not carry the prompt (e.g. Claude Code) needs an
952
- # implicit init so ownership and the sidecar exist before append.
953
- side = read_sidecar(p)
954
- if side is None and fum:
955
- hook_append("session_start", first_user_msg=fum,
956
- path=path, settings_path=settings_path)
957
-
958
- return hook_append(event, first_user_msg=fum, payload=hook_payload,
1371
+ return hook_append(event, session_id=session_id, payload=hook_payload,
959
1372
  path=path, settings_path=settings_path)
960
1373
 
961
1374
 
962
1375
  def _cmd_init(args) -> int:
963
- h = init(args.first_user_msg, freq=args.freq)
1376
+ h = init(freq=args.freq)
964
1377
  print(json.dumps(h, ensure_ascii=False))
965
1378
  return 0
966
1379
 
@@ -982,7 +1395,7 @@ def _cmd_hook_append(args) -> int:
982
1395
  try:
983
1396
  result = hook_append(
984
1397
  args.event,
985
- first_user_msg=args.first_user_msg,
1398
+ session_id=args.session_id,
986
1399
  payload=payload,
987
1400
  settings_path=settings_path,
988
1401
  )
@@ -990,8 +1403,6 @@ def _cmd_hook_append(args) -> int:
990
1403
  print(f"error: {exc}", file=sys.stderr)
991
1404
  return EXIT_BAD_ARGS
992
1405
  print(json.dumps(result, ensure_ascii=False))
993
- if result.get("action") == "ownership_refused":
994
- return EXIT_OWNERSHIP_REFUSED
995
1406
  return EXIT_OK
996
1407
 
997
1408
 
@@ -1009,8 +1420,6 @@ def _cmd_hook_dispatch(args) -> int:
1009
1420
  print(f"error: {exc}", file=sys.stderr)
1010
1421
  return EXIT_BAD_ARGS
1011
1422
  print(json.dumps(result, ensure_ascii=False))
1012
- if result.get("action") == "ownership_refused":
1013
- return EXIT_OWNERSHIP_REFUSED
1014
1423
  return EXIT_OK
1015
1424
 
1016
1425
 
@@ -1021,17 +1430,8 @@ def _cmd_append(args) -> int:
1021
1430
  print("error: --type or a 't' key in --json is required",
1022
1431
  file=sys.stderr)
1023
1432
  return EXIT_BAD_ARGS
1024
- try:
1025
- append(entry, first_user_msg=args.first_user_msg)
1026
- except OwnershipError as exc:
1027
- print(
1028
- f"error: append refused — state={exc.state}; "
1029
- f"header_fp={exc.header_fp[:8]} current_fp={exc.current_fp[:8]}. "
1030
- f"Run `chat_history.py turn-check --first-user-msg \"...\"` "
1031
- f"and resolve ownership before retrying.",
1032
- file=sys.stderr,
1033
- )
1034
- return EXIT_OWNERSHIP_REFUSED
1433
+ session = derive_session_tag(args.session_id) if args.session_id else None
1434
+ append(entry, session=session)
1035
1435
  return EXIT_OK
1036
1436
 
1037
1437
 
@@ -1040,91 +1440,6 @@ def _cmd_status(_args) -> int:
1040
1440
  return 0
1041
1441
 
1042
1442
 
1043
- def _cmd_check(args) -> int:
1044
- print(check_ownership(args.first_user_msg))
1045
- return 0
1046
-
1047
-
1048
- def _cmd_state(args) -> int:
1049
- print(ownership_state(args.first_user_msg))
1050
- return 0
1051
-
1052
-
1053
- def _format_turn_check_stdout(result: dict[str, Any]) -> str:
1054
- """Render turn_check() result as a single key=value line for shell parsing."""
1055
- state = result["state"]
1056
- parts = [f"state={state}"]
1057
- if "entries" in result:
1058
- parts.append(f"entries={result['entries']}")
1059
- if state in {"foreign", "returning"}:
1060
- parts.append(f"header_fp={str(result.get('header_fp', ''))[:8]}")
1061
- parts.append(f"current_fp={str(result.get('current_fp', ''))[:8]}")
1062
- preview = str(result.get("preview", "")).replace('"', "'")
1063
- if preview:
1064
- parts.append(f'preview="{preview[:80]}"')
1065
- return " ".join(parts)
1066
-
1067
-
1068
- def _turn_check_action_hint(state: str) -> str:
1069
- """Stderr hint telling the agent which prompt to render."""
1070
- if state == "ok":
1071
- return ""
1072
- if state == "disabled":
1073
- return ""
1074
- if state == "missing":
1075
- return ("ACTION REQUIRED: state=missing — run "
1076
- "`chat_history.py init --first-user-msg \"...\" "
1077
- "--freq <frequency-from-settings>` before any other reply.")
1078
- if state == "foreign":
1079
- return ("ACTION REQUIRED: state=foreign — render the Foreign-Prompt "
1080
- "from the chat-history rule (3 numbered options: Resume / "
1081
- "New start / Ignore) before any other reply. Do not append "
1082
- "to this file until the user picks.")
1083
- if state == "returning":
1084
- return ("ACTION REQUIRED: state=returning — render the "
1085
- "Returning-Prompt from the chat-history rule (3 numbered "
1086
- "options: Merge / Replace / Continue) before any other "
1087
- "reply. Do not append to this file until the user picks.")
1088
- return f"ACTION REQUIRED: unknown state={state}"
1089
-
1090
-
1091
- def _cmd_turn_check(args) -> int:
1092
- settings_path = Path(args.settings) if args.settings else None
1093
- result = turn_check(args.first_user_msg, settings_path=settings_path)
1094
- print(_format_turn_check_stdout(result))
1095
- hint = _turn_check_action_hint(result["state"])
1096
- if hint:
1097
- print(hint, file=sys.stderr)
1098
- return int(result["exit"])
1099
-
1100
-
1101
- def _cmd_heartbeat(args) -> int:
1102
- settings_path = Path(args.settings) if args.settings else None
1103
- result = heartbeat(args.first_user_msg, settings_path=settings_path)
1104
- if args.json:
1105
- # JSON consumers want the full record regardless of mode.
1106
- print(json.dumps(result, ensure_ascii=False))
1107
- return EXIT_OK
1108
- mode = _read_chat_history_heartbeat_mode(
1109
- settings_path or Path(DEFAULT_SETTINGS_FILE)
1110
- )
1111
- state = str(result.get("state", ""))
1112
- # off → never print. hybrid → only on drift states.
1113
- # on → always (current behavior).
1114
- if mode == "off":
1115
- return EXIT_OK
1116
- if mode == "hybrid" and state not in DRIFT_STATES:
1117
- return EXIT_OK
1118
- print(result["marker"])
1119
- return EXIT_OK
1120
-
1121
-
1122
- def _cmd_adopt(args) -> int:
1123
- h = adopt(args.first_user_msg)
1124
- print(json.dumps(h, ensure_ascii=False))
1125
- return 0
1126
-
1127
-
1128
1443
  def _load_entries_arg(args) -> list[dict[str, Any]]:
1129
1444
  if getattr(args, "entries_stdin", False):
1130
1445
  raw = sys.stdin.read()
@@ -1141,11 +1456,23 @@ def _load_entries_arg(args) -> list[dict[str, Any]]:
1141
1456
 
1142
1457
  def _cmd_reset(args) -> int:
1143
1458
  entries = _load_entries_arg(args)
1144
- h = reset_with_entries(args.first_user_msg, entries, freq=args.freq)
1459
+ h = reset_with_entries(entries, freq=args.freq)
1145
1460
  print(json.dumps(h, ensure_ascii=False))
1146
1461
  return 0
1147
1462
 
1148
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
+
1149
1476
  def _cmd_prepend(args) -> int:
1150
1477
  entries = _load_entries_arg(args)
1151
1478
  n = prepend_entries(entries)
@@ -1160,11 +1487,46 @@ def _cmd_clear(_args) -> int:
1160
1487
 
1161
1488
  def _cmd_read(args) -> int:
1162
1489
  last = None if args.all else args.last
1163
- entries = read_entries(last=last)
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)
1164
1496
  print(json.dumps(entries, ensure_ascii=False, indent=2))
1165
1497
  return 0
1166
1498
 
1167
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
+
1168
1530
  def _cmd_rotate(args) -> int:
1169
1531
  result = overflow_handle(args.max_kb, mode=args.mode)
1170
1532
  print(json.dumps(result, ensure_ascii=False))
@@ -1175,61 +1537,20 @@ def main(argv: list[str] | None = None) -> int:
1175
1537
  ap = argparse.ArgumentParser(description=__doc__)
1176
1538
  sub = ap.add_subparsers(dest="cmd", required=True)
1177
1539
  p_init = sub.add_parser("init")
1178
- p_init.add_argument("--first-user-msg", required=True)
1179
1540
  p_init.add_argument("--freq", default="per_phase", choices=sorted(VALID_FREQS))
1180
1541
  p_init.set_defaults(func=_cmd_init)
1181
1542
  p_app = sub.add_parser("append")
1182
1543
  p_app.add_argument("--type", help="entry type (t field)")
1183
1544
  p_app.add_argument("--json", help="JSON object with entry fields")
1184
1545
  p_app.add_argument(
1185
- "--first-user-msg",
1546
+ "--session-id",
1186
1547
  default=None,
1187
- help=("validate ownership before writing refuses with exit "
1188
- f"{EXIT_OWNERSHIP_REFUSED} on foreign/returning/missing"),
1548
+ help=("platform session id; hashed to derive the body 's' tag. "
1549
+ "Omit to write entries with s=<unknown>."),
1189
1550
  )
1190
1551
  p_app.set_defaults(func=_cmd_append)
1191
1552
  sub.add_parser("status").set_defaults(func=_cmd_status)
1192
- p_chk = sub.add_parser("check")
1193
- p_chk.add_argument("--first-user-msg", required=True)
1194
- p_chk.set_defaults(func=_cmd_check)
1195
- p_state = sub.add_parser("state")
1196
- p_state.add_argument("--first-user-msg", required=True)
1197
- p_state.set_defaults(func=_cmd_state)
1198
- p_tc = sub.add_parser(
1199
- "turn-check",
1200
- help=("turn-start ownership gate; exit 0=ok/disabled, "
1201
- f"{EXIT_MISSING}=missing, {EXIT_FOREIGN}=foreign, "
1202
- f"{EXIT_RETURNING}=returning"),
1203
- )
1204
- p_tc.add_argument("--first-user-msg", required=True)
1205
- p_tc.add_argument(
1206
- "--settings",
1207
- default=None,
1208
- help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1209
- )
1210
- p_tc.set_defaults(func=_cmd_turn_check)
1211
- p_hb = sub.add_parser(
1212
- "heartbeat",
1213
- help=("emit the in-band reply marker; always exit 0. "
1214
- "Agent must include the stdout line verbatim in every reply."),
1215
- )
1216
- p_hb.add_argument("--first-user-msg", required=True)
1217
- p_hb.add_argument(
1218
- "--settings",
1219
- default=None,
1220
- help=f"path to agent settings (default: {DEFAULT_SETTINGS_FILE})",
1221
- )
1222
- p_hb.add_argument(
1223
- "--json",
1224
- action="store_true",
1225
- help="emit the full result dict instead of just the marker",
1226
- )
1227
- p_hb.set_defaults(func=_cmd_heartbeat)
1228
- p_ado = sub.add_parser("adopt")
1229
- p_ado.add_argument("--first-user-msg", required=True)
1230
- p_ado.set_defaults(func=_cmd_adopt)
1231
1553
  p_reset = sub.add_parser("reset")
1232
- p_reset.add_argument("--first-user-msg", required=True)
1233
1554
  p_reset.add_argument("--freq", default="per_phase",
1234
1555
  choices=sorted(VALID_FREQS))
1235
1556
  g_r = p_reset.add_mutually_exclusive_group(required=True)
@@ -1238,6 +1559,23 @@ def main(argv: list[str] | None = None) -> int:
1238
1559
  g_r.add_argument("--entries-stdin", action="store_true",
1239
1560
  help="read JSON array from stdin")
1240
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)
1241
1579
  p_prep = sub.add_parser("prepend")
1242
1580
  g_p = p_prep.add_mutually_exclusive_group(required=True)
1243
1581
  g_p.add_argument("--entries-json",
@@ -1251,28 +1589,46 @@ def main(argv: list[str] | None = None) -> int:
1251
1589
  grp.add_argument("--last", type=int, default=5,
1252
1590
  help="return the trailing N entries (default: 5)")
1253
1591
  grp.add_argument("--all", action="store_true",
1254
- 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
+ )
1255
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)
1256
1612
  p_rot = sub.add_parser("rotate")
1257
1613
  p_rot.add_argument("--max-kb", type=int, default=256)
1258
1614
  p_rot.add_argument("--mode", default="rotate", choices=sorted(VALID_OVERFLOW))
1259
1615
  p_rot.set_defaults(func=_cmd_rotate)
1260
1616
  p_hook = sub.add_parser(
1261
1617
  "hook-append",
1262
- help=("platform-hook entry point — wraps init/append/sidecar; "
1263
- "stateless after the first session_start call"),
1618
+ help=("platform-hook entry point — stateless append per session; "
1619
+ "derives the body 's' tag from --session-id"),
1264
1620
  )
1265
1621
  p_hook.add_argument(
1266
1622
  "--event",
1267
1623
  required=True,
1268
1624
  choices=sorted(VALID_HOOK_EVENTS),
1269
- help="hook event name (session_start required first)",
1625
+ help="hook event name",
1270
1626
  )
1271
1627
  p_hook.add_argument(
1272
- "--first-user-msg",
1628
+ "--session-id",
1273
1629
  default=None,
1274
- help=("required on session_start; subsequent events read it from "
1275
- "the sidecar"),
1630
+ help=("platform session id; hashed to derive the body 's' tag. "
1631
+ "Omit to write entries with s=<unknown>."),
1276
1632
  )
1277
1633
  p_hook.add_argument(
1278
1634
  "--payload",