agentdir-cli 0.7.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentdir/context.py ADDED
@@ -0,0 +1,654 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from .artifacts import add_artifact, artifact_headers, artifact_path
12
+ from .events import emit_event
13
+ from .federation import search_federated_memory
14
+ from .index import connect_index, rebuild_index
15
+ from .memory import DEFAULT_MIN_SCORE, RETRIEVAL_HYBRID, recent_session_summaries, search_memory
16
+ from .query import query_messages
17
+ from .review import evidence_rows, format_evidence, format_summary, summarize_session
18
+ from .sessions import read_current_session
19
+ from .store import AgentDirError
20
+
21
+ CONTEXT_PROTOCOL = "agentdir.context-pack.v1"
22
+ CONTEXT_ENFORCEMENT_MODE = "advisory"
23
+ EVENT_CONTEXT_PACK_CREATED = "context.pack.created"
24
+ EVENT_CONTEXT_PACK_CONSUMED = "context.pack.consumed"
25
+ EVENT_CONTEXT_SOURCES_CITED = "context.sources.cited"
26
+ HEADER_PROTOCOL = "X-AgentDir-Protocol"
27
+ HEADER_PACK_ID = "X-AgentDir-Pack-Id"
28
+ HEADER_CONTEXT_QUERY = "X-AgentDir-Context-Query"
29
+ HEADER_CONTEXT_SCOPE = "X-AgentDir-Context-Scope"
30
+ HEADER_SOURCE_ID = "X-AgentDir-Source-Id"
31
+ HEADER_CONSUMPTION_PURPOSE = "X-AgentDir-Consumption-Purpose"
32
+ HEADER_ENFORCEMENT_MODE = "X-AgentDir-Enforcement-Mode"
33
+ CONSUMPTION_PURPOSES = ("plan", "tool", "answer", "handoff")
34
+ EVIDENCE_EVENTS = {"tool.call", "tool.result", "file.diff"}
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class EmittedContextPack:
39
+ manifest: dict[str, Any]
40
+ event_path: Path
41
+ artifact_sha256: str
42
+ artifact_path: Path
43
+
44
+
45
+ def build_context_pack(
46
+ root: str | Path,
47
+ task: str,
48
+ *,
49
+ session_id: str | None = None,
50
+ memory_limit: int = 8,
51
+ evidence_limit: int = 20,
52
+ recent_limit: int = 5,
53
+ min_score: float = DEFAULT_MIN_SCORE,
54
+ federated: bool = False,
55
+ federation_group: str | None = None,
56
+ retrieval_mode: str = RETRIEVAL_HYBRID,
57
+ exclude_session_from_memory: bool = False,
58
+ ) -> dict[str, Any]:
59
+ rebuild_index(root)
60
+ resolved_session = session_id
61
+ if resolved_session is None:
62
+ current = read_current_session(root)
63
+ resolved_session = current.session_id if current else None
64
+
65
+ search_limit = memory_limit if not (resolved_session and exclude_session_from_memory) else max(memory_limit * 3, memory_limit + 5)
66
+ memory_hits = (
67
+ search_federated_memory(
68
+ root,
69
+ task,
70
+ limit=search_limit,
71
+ min_score=min_score,
72
+ retrieval_mode=retrieval_mode,
73
+ group=federation_group,
74
+ )
75
+ if federated or federation_group
76
+ else search_memory(
77
+ root,
78
+ task,
79
+ limit=search_limit,
80
+ min_score=min_score,
81
+ retrieval_mode=retrieval_mode,
82
+ )
83
+ )
84
+ if resolved_session and exclude_session_from_memory:
85
+ memory_hits = [row for row in memory_hits if row.get("session_id") != resolved_session][:memory_limit]
86
+ recent = [
87
+ row
88
+ for row in recent_session_summaries(root, limit=recent_limit + 5)
89
+ if row.get("session_id") != resolved_session
90
+ ][:recent_limit]
91
+ else:
92
+ memory_hits = memory_hits[:memory_limit]
93
+ recent = recent_session_summaries(root, limit=recent_limit)
94
+ evidence = evidence_rows(root, resolved_session, rebuild=False)[:evidence_limit] if resolved_session else []
95
+ current_summary = summarize_session(root, resolved_session, rebuild=False) if resolved_session else None
96
+
97
+ return {
98
+ "task": task,
99
+ "session_id": resolved_session,
100
+ "current_summary": current_summary,
101
+ "memory_hits": memory_hits,
102
+ "federated": bool(federated or federation_group),
103
+ "federation_group": federation_group,
104
+ "retrieval_mode": retrieval_mode,
105
+ "recent_session_summaries": recent,
106
+ "evidence": evidence,
107
+ "instructions": [
108
+ "Use memory hits as retrieval hints, not proof.",
109
+ "Use evidence rows for claims about commands, hooks, and diffs.",
110
+ "Run fresh verification before reporting completion.",
111
+ ],
112
+ }
113
+
114
+
115
+ def build_context_manifest(
116
+ pack: dict[str, Any],
117
+ *,
118
+ pack_id: str | None = None,
119
+ selection_policy: dict[str, Any] | None = None,
120
+ ) -> dict[str, Any]:
121
+ resolved_pack_id = pack_id or new_pack_id()
122
+ memory_sources = [_source_entry(row, origin="memory_hit") for row in pack.get("memory_hits") or []]
123
+ recent_summaries = [
124
+ _source_entry(row, origin="recent_summary")
125
+ for row in pack.get("recent_session_summaries") or []
126
+ ]
127
+ evidence = [_source_entry(row, origin="evidence") for row in pack.get("evidence") or []]
128
+ sources = _dedupe_sources([*memory_sources, *recent_summaries, *evidence])
129
+ return {
130
+ "protocol": CONTEXT_PROTOCOL,
131
+ "pack_id": resolved_pack_id,
132
+ "task": pack["task"],
133
+ "session_id": pack.get("session_id"),
134
+ "generated_at": now_iso(),
135
+ "selection_policy": selection_policy or {},
136
+ "federated": bool(pack.get("federated")),
137
+ "federation_group": pack.get("federation_group"),
138
+ "retrieval_mode": pack.get("retrieval_mode") or RETRIEVAL_HYBRID,
139
+ "sources": sources,
140
+ "memory_hits": memory_sources,
141
+ "recent_summaries": recent_summaries,
142
+ "evidence": evidence,
143
+ "instructions": pack.get("instructions") or [],
144
+ "enforcement_boundary": (
145
+ "advisory: AgentDir records that a cooperative agent requested, "
146
+ "consumed, and cited context, but it cannot prove model attention."
147
+ ),
148
+ "source_counts": _source_counts(sources),
149
+ }
150
+
151
+
152
+ def emit_context_pack(
153
+ root: str | Path,
154
+ pack: dict[str, Any],
155
+ *,
156
+ selection_policy: dict[str, Any] | None = None,
157
+ actor: str = "agent",
158
+ scope: str = "project",
159
+ ) -> EmittedContextPack:
160
+ session_id = pack.get("session_id")
161
+ if not session_id:
162
+ raise AgentDirError("Context pack emission requires an active or explicit session")
163
+ manifest = build_context_manifest(pack, selection_policy=selection_policy)
164
+ artifact_file = _write_manifest_temp(manifest)
165
+ try:
166
+ artifact = add_artifact(root, artifact_file)
167
+ finally:
168
+ artifact_file.unlink(missing_ok=True)
169
+ body = "\n".join(
170
+ [
171
+ f"pack_id={manifest['pack_id']}",
172
+ f"protocol={CONTEXT_PROTOCOL}",
173
+ f"task={manifest['task']}",
174
+ f"session_id={session_id}",
175
+ f"sources={len(manifest['sources'])}",
176
+ f"artifact_sha256={artifact.sha256}",
177
+ "",
178
+ manifest["enforcement_boundary"],
179
+ ]
180
+ )
181
+ headers: dict[str, str | list[str]] = {
182
+ HEADER_PROTOCOL: CONTEXT_PROTOCOL,
183
+ HEADER_PACK_ID: manifest["pack_id"],
184
+ HEADER_CONTEXT_QUERY: manifest["task"],
185
+ HEADER_CONTEXT_SCOPE: scope,
186
+ HEADER_ENFORCEMENT_MODE: CONTEXT_ENFORCEMENT_MODE,
187
+ HEADER_SOURCE_ID: [source["source_id"] for source in manifest["sources"]],
188
+ **artifact_headers(artifact),
189
+ }
190
+ event = emit_event(
191
+ root,
192
+ session_id=session_id,
193
+ event_type=EVENT_CONTEXT_PACK_CREATED,
194
+ subject=f"context pack: {manifest['task']}",
195
+ body=body,
196
+ from_actor=actor,
197
+ extra_headers=headers,
198
+ )
199
+ return EmittedContextPack(
200
+ manifest=manifest,
201
+ event_path=event.path,
202
+ artifact_sha256=artifact.sha256,
203
+ artifact_path=artifact.path,
204
+ )
205
+
206
+
207
+ def read_context_manifest(root: str | Path, pack_id: str, *, rebuild: bool = True) -> dict[str, Any]:
208
+ event = _context_event(root, pack_id, EVENT_CONTEXT_PACK_CREATED, rebuild=rebuild)
209
+ sha = event["headers"].get("X-AgentDir-Blob-SHA256")
210
+ if not sha:
211
+ raise AgentDirError(f"Context pack has no manifest artifact: {pack_id}")
212
+ manifest_file = artifact_path(root, sha)
213
+ if not manifest_file.is_file():
214
+ raise AgentDirError(f"Context pack manifest artifact is missing: {sha}")
215
+ manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
216
+ if manifest.get("protocol") != CONTEXT_PROTOCOL:
217
+ raise AgentDirError(f"Unsupported context pack protocol: {manifest.get('protocol')}")
218
+ return manifest
219
+
220
+
221
+ def consume_context_sources(
222
+ root: str | Path,
223
+ *,
224
+ pack_id: str,
225
+ source_ids: list[str],
226
+ purpose: str,
227
+ session_id: str | None = None,
228
+ actor: str = "agent",
229
+ ) -> dict[str, Any]:
230
+ if purpose not in CONSUMPTION_PURPOSES:
231
+ raise AgentDirError(f"Unknown consumption purpose: {purpose}")
232
+ manifest = read_context_manifest(root, pack_id)
233
+ selected = _select_manifest_sources(manifest, source_ids)
234
+ resolved_session = session_id or manifest.get("session_id")
235
+ if not resolved_session:
236
+ raise AgentDirError("Context consumption requires a session")
237
+ body = _format_source_event_body("context_consumed", pack_id, purpose, selected)
238
+ event = emit_event(
239
+ root,
240
+ session_id=resolved_session,
241
+ event_type=EVENT_CONTEXT_PACK_CONSUMED,
242
+ subject=f"context consumed: {pack_id}",
243
+ body=body,
244
+ from_actor=actor,
245
+ extra_headers={
246
+ HEADER_PROTOCOL: CONTEXT_PROTOCOL,
247
+ HEADER_PACK_ID: pack_id,
248
+ HEADER_SOURCE_ID: [source["source_id"] for source in selected],
249
+ HEADER_CONSUMPTION_PURPOSE: purpose,
250
+ HEADER_ENFORCEMENT_MODE: CONTEXT_ENFORCEMENT_MODE,
251
+ },
252
+ )
253
+ return {
254
+ "pack_id": pack_id,
255
+ "purpose": purpose,
256
+ "source_ids": [source["source_id"] for source in selected],
257
+ "event_path": str(event.path),
258
+ "enforcement_mode": CONTEXT_ENFORCEMENT_MODE,
259
+ }
260
+
261
+
262
+ def cite_context_sources(
263
+ root: str | Path,
264
+ *,
265
+ pack_id: str,
266
+ source_ids: list[str] | None = None,
267
+ output_format: str = "md",
268
+ session_id: str | None = None,
269
+ actor: str = "agent",
270
+ ) -> dict[str, Any]:
271
+ manifest = read_context_manifest(root, pack_id)
272
+ selected = _select_manifest_sources(manifest, source_ids or _default_citation_sources(root, pack_id, manifest))
273
+ resolved_session = session_id or manifest.get("session_id")
274
+ if not resolved_session:
275
+ raise AgentDirError("Context citation requires a session")
276
+ citation = {
277
+ "protocol": CONTEXT_PROTOCOL,
278
+ "pack_id": pack_id,
279
+ "generated_at": now_iso(),
280
+ "enforcement_mode": CONTEXT_ENFORCEMENT_MODE,
281
+ "sources": selected,
282
+ "source_counts": _source_counts(selected),
283
+ }
284
+ rendered = format_context_citation(citation, output_format=output_format)
285
+ event = emit_event(
286
+ root,
287
+ session_id=resolved_session,
288
+ event_type=EVENT_CONTEXT_SOURCES_CITED,
289
+ subject=f"context cited: {pack_id}",
290
+ body=rendered,
291
+ from_actor=actor,
292
+ extra_headers={
293
+ HEADER_PROTOCOL: CONTEXT_PROTOCOL,
294
+ HEADER_PACK_ID: pack_id,
295
+ HEADER_SOURCE_ID: [source["source_id"] for source in selected],
296
+ HEADER_ENFORCEMENT_MODE: CONTEXT_ENFORCEMENT_MODE,
297
+ },
298
+ )
299
+ citation["event_path"] = str(event.path)
300
+ citation["rendered"] = format_context_citation(citation, output_format=output_format)
301
+ return citation
302
+
303
+
304
+ def audit_context_pack(root: str | Path, pack_id: str, *, rebuild: bool = True) -> dict[str, Any]:
305
+ if rebuild:
306
+ rebuild_index(root)
307
+ manifest = read_context_manifest(root, pack_id, rebuild=False)
308
+ events = _context_events(root, pack_id, rebuild=False)
309
+ consumed = _unique_source_ids(
310
+ source_id
311
+ for event in events
312
+ if event["event_type"] == EVENT_CONTEXT_PACK_CONSUMED
313
+ for source_id in event["source_ids"]
314
+ )
315
+ cited = _unique_source_ids(
316
+ source_id
317
+ for event in events
318
+ if event["event_type"] == EVENT_CONTEXT_SOURCES_CITED
319
+ for source_id in event["source_ids"]
320
+ )
321
+ source_by_id = {source["source_id"]: source for source in manifest["sources"]}
322
+ evidence_backed = [
323
+ source_id
324
+ for source_id in cited
325
+ if source_by_id.get(source_id, {}).get("source_class") == "evidence"
326
+ ]
327
+ return {
328
+ "protocol": CONTEXT_PROTOCOL,
329
+ "pack_id": pack_id,
330
+ "task": manifest.get("task"),
331
+ "session_id": manifest.get("session_id"),
332
+ "retrieved_count": len(manifest["sources"]),
333
+ "consumed_count": len(consumed),
334
+ "cited_count": len(cited),
335
+ "evidence_backed_count": len(evidence_backed),
336
+ "source_counts": manifest.get("source_counts", {}),
337
+ "consumed_source_ids": consumed,
338
+ "cited_source_ids": cited,
339
+ "evidence_backed_source_ids": evidence_backed,
340
+ "events": events,
341
+ "enforcement_mode": CONTEXT_ENFORCEMENT_MODE,
342
+ }
343
+
344
+
345
+ def format_context_pack(pack: dict[str, Any]) -> str:
346
+ lines = [
347
+ "# AgentDir Context Pack",
348
+ "",
349
+ f"Task: {pack['task']}",
350
+ f"Session: {pack.get('session_id') or 'none'}",
351
+ ]
352
+
353
+ summary = pack.get("current_summary")
354
+ if summary:
355
+ lines.extend(["", "## Current Session", "", fenced(format_summary(summary), "text")])
356
+
357
+ lines.extend(["", "## Relevant Memory"])
358
+ memory_hits = pack.get("memory_hits") or []
359
+ if memory_hits:
360
+ for row in memory_hits:
361
+ lines.extend(
362
+ [
363
+ "",
364
+ f"- score={row.get('memory_score')} source={row.get('source_id')} kind={row.get('source_kind')}",
365
+ f" session={row.get('session_id') or ''} event={row.get('event_type') or ''}",
366
+ f" subject={row.get('subject') or ''}",
367
+ f" excerpt={excerpt(row.get('body_text') or '', 320)}",
368
+ ]
369
+ )
370
+ else:
371
+ lines.extend(["", "No relevant memory found."])
372
+
373
+ evidence = pack.get("evidence") or []
374
+ if evidence:
375
+ lines.extend(["", "## Current Evidence", "", fenced(format_evidence(evidence), "text")])
376
+
377
+ recent = pack.get("recent_session_summaries") or []
378
+ if recent:
379
+ lines.extend(["", "## Recent Session Summaries"])
380
+ for row in recent:
381
+ lines.extend(
382
+ [
383
+ "",
384
+ f"- source={row.get('source_id')} session={row.get('session_id') or ''}",
385
+ f" {excerpt(row.get('body_text') or '', 420)}",
386
+ ]
387
+ )
388
+
389
+ lines.extend(["", "## Agent Instructions"])
390
+ for instruction in pack["instructions"]:
391
+ lines.append(f"- {instruction}")
392
+ return "\n".join(lines).rstrip() + "\n"
393
+
394
+
395
+ def write_context_pack(path: str | Path, pack: dict[str, Any], *, as_json: bool = False) -> Path:
396
+ target = Path(path).expanduser()
397
+ target.parent.mkdir(parents=True, exist_ok=True)
398
+ if as_json:
399
+ target.write_text(json.dumps(pack, indent=2, sort_keys=True) + "\n", encoding="utf-8")
400
+ else:
401
+ target.write_text(format_context_pack(pack), encoding="utf-8")
402
+ return target
403
+
404
+
405
+ def format_context_citation(citation: dict[str, Any], *, output_format: str = "md") -> str:
406
+ if output_format == "json":
407
+ payload = {key: value for key, value in citation.items() if key != "rendered"}
408
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
409
+ if output_format != "md":
410
+ raise AgentDirError(f"Unknown citation format: {output_format}")
411
+ lines = [
412
+ "# AgentDir Context Citations",
413
+ "",
414
+ f"Pack: {citation['pack_id']}",
415
+ f"Enforcement: {citation['enforcement_mode']}",
416
+ "",
417
+ ]
418
+ for source in citation["sources"]:
419
+ lines.append(
420
+ f"- `{source['source_class']}` `{source['source_id']}` "
421
+ f"{source.get('event_type') or ''} {source.get('subject') or ''}".rstrip()
422
+ )
423
+ if source.get("file_path"):
424
+ lines.append(f" file: `{source['file_path']}`")
425
+ if source.get("excerpt"):
426
+ lines.append(f" excerpt: {source['excerpt']}")
427
+ return "\n".join(lines).rstrip() + "\n"
428
+
429
+
430
+ def format_context_audit(audit: dict[str, Any]) -> str:
431
+ lines = [
432
+ f"pack_id={audit['pack_id']}",
433
+ f"task={audit.get('task') or ''}",
434
+ f"session={audit.get('session_id') or ''}",
435
+ f"retrieved={audit['retrieved_count']}",
436
+ f"consumed={audit['consumed_count']}",
437
+ f"cited={audit['cited_count']}",
438
+ f"evidence_backed={audit['evidence_backed_count']}",
439
+ f"enforcement={audit['enforcement_mode']}",
440
+ ]
441
+ for event in audit["events"]:
442
+ lines.append(
443
+ f"{event.get('date_utc') or event.get('indexed_at') or ''} "
444
+ f"{event['event_type']} sources={len(event['source_ids'])}"
445
+ )
446
+ return "\n".join(lines)
447
+
448
+
449
+ def fenced(text: str, language: str) -> str:
450
+ return f"```{language}\n{text}\n```"
451
+
452
+
453
+ def excerpt(text: str, limit: int) -> str:
454
+ collapsed = " ".join(text.strip().split())
455
+ if len(collapsed) <= limit:
456
+ return collapsed
457
+ return collapsed[: limit - 3] + "..."
458
+
459
+
460
+ def now_iso() -> str:
461
+ return datetime.now(UTC).isoformat()
462
+
463
+
464
+ def new_pack_id() -> str:
465
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
466
+ return f"ctx-{stamp}-{uuid4().hex[:12]}"
467
+
468
+
469
+ def _source_entry(row: dict[str, Any], *, origin: str) -> dict[str, Any]:
470
+ source_id = _source_id(row)
471
+ body = row.get("body_text") or ""
472
+ return {
473
+ "source_id": source_id,
474
+ "source_kind": row.get("source_kind") or "message",
475
+ "source_class": _source_class(row, origin),
476
+ "origin": origin,
477
+ "message_id": row.get("message_id"),
478
+ "message_rowid": row.get("message_rowid") or row.get("id"),
479
+ "session_id": row.get("session_id"),
480
+ "event_type": row.get("event_type"),
481
+ "subject": row.get("subject"),
482
+ "from_actor": row.get("from_actor"),
483
+ "to_actor": row.get("to_actor"),
484
+ "task_id": row.get("task_id"),
485
+ "tool": row.get("tool"),
486
+ "tool_exit_code": row.get("tool_exit_code"),
487
+ "git_head": row.get("git_head"),
488
+ "workspace": row.get("workspace"),
489
+ "source_root_id": row.get("source_root_id"),
490
+ "source_root_name": row.get("source_root_name"),
491
+ "source_root_path": row.get("source_root_path"),
492
+ "source_root_visibility": row.get("source_root_visibility"),
493
+ "source_id_original": row.get("source_id_original"),
494
+ "source_file_path": row.get("source_file_path"),
495
+ "date_utc": row.get("date_utc"),
496
+ "indexed_at": row.get("indexed_at"),
497
+ "file_path": row.get("file_path"),
498
+ "memory_score": row.get("memory_score"),
499
+ "body_sha256": row.get("body_sha256"),
500
+ "text_sha256": row.get("text_sha256"),
501
+ "excerpt": excerpt(body, 320),
502
+ }
503
+
504
+
505
+ def _source_id(row: dict[str, Any]) -> str:
506
+ if row.get("source_id"):
507
+ return str(row["source_id"])
508
+ if row.get("file_path"):
509
+ return f"message:{row['file_path']}"
510
+ if row.get("message_id"):
511
+ return f"message-id:{row['message_id']}"
512
+ return f"source:{uuid4().hex}"
513
+
514
+
515
+ def _source_class(row: dict[str, Any], origin: str) -> str:
516
+ event_type = str(row.get("event_type") or "")
517
+ source_kind = str(row.get("source_kind") or "")
518
+ if origin == "evidence" or event_type in EVIDENCE_EVENTS or event_type.startswith("git.hook."):
519
+ return "evidence"
520
+ if source_kind == "session_summary" or event_type == "summary.compacted":
521
+ return "summary"
522
+ return "retrieval_hint"
523
+
524
+
525
+ def _dedupe_sources(sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
526
+ seen: set[str] = set()
527
+ deduped: list[dict[str, Any]] = []
528
+ for source in sources:
529
+ source_id = source["source_id"]
530
+ if source_id in seen:
531
+ continue
532
+ seen.add(source_id)
533
+ deduped.append(source)
534
+ return deduped
535
+
536
+
537
+ def _source_counts(sources: list[dict[str, Any]]) -> dict[str, int]:
538
+ counts = {"evidence": 0, "retrieval_hint": 0, "summary": 0}
539
+ for source in sources:
540
+ source_class = source.get("source_class") or "retrieval_hint"
541
+ counts[source_class] = counts.get(source_class, 0) + 1
542
+ return counts
543
+
544
+
545
+ def _write_manifest_temp(manifest: dict[str, Any]) -> Path:
546
+ handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".json")
547
+ with handle:
548
+ json.dump(manifest, handle, indent=2, sort_keys=True)
549
+ handle.write("\n")
550
+ return Path(handle.name)
551
+
552
+
553
+ def _context_event(root: str | Path, pack_id: str, event_type: str, *, rebuild: bool = True) -> dict[str, Any]:
554
+ events = _context_events(root, pack_id, event_type=event_type, rebuild=rebuild)
555
+ if not events:
556
+ raise AgentDirError(f"Unknown context pack: {pack_id}")
557
+ return events[-1]
558
+
559
+
560
+ def _context_events(
561
+ root: str | Path,
562
+ pack_id: str,
563
+ *,
564
+ event_type: str | None = None,
565
+ rebuild: bool = True,
566
+ ) -> list[dict[str, Any]]:
567
+ if rebuild:
568
+ rebuild_index(root)
569
+ clauses = ["hp.name = ?", "hp.value = ?"]
570
+ params: list[Any] = [HEADER_PACK_ID, pack_id]
571
+ if event_type:
572
+ clauses.append("m.event_type = ?")
573
+ params.append(event_type)
574
+ sql = f"""
575
+ select distinct m.*
576
+ from messages m
577
+ join headers hp on hp.message_rowid = m.id
578
+ where {' and '.join(clauses)}
579
+ order by coalesce(m.date_utc, m.indexed_at), coalesce(m.created_ns, 0), m.file_path, m.id
580
+ """
581
+ with connect_index(root) as conn:
582
+ rows = [dict(row) for row in conn.execute(sql, params).fetchall()]
583
+ for row in rows:
584
+ headers = conn.execute(
585
+ "select name, value from headers where message_rowid = ? order by rowid",
586
+ (row["id"],),
587
+ ).fetchall()
588
+ row["headers"] = _header_map(headers)
589
+ row["source_ids"] = [header["value"] for header in headers if header["name"] == HEADER_SOURCE_ID]
590
+ return rows
591
+
592
+
593
+ def _header_map(rows: list[Any]) -> dict[str, str]:
594
+ mapped: dict[str, str] = {}
595
+ for row in rows:
596
+ name = row["name"]
597
+ value = row["value"]
598
+ if name not in mapped:
599
+ mapped[name] = value
600
+ return mapped
601
+
602
+
603
+ def _select_manifest_sources(
604
+ manifest: dict[str, Any],
605
+ source_ids: list[str],
606
+ ) -> list[dict[str, Any]]:
607
+ if not source_ids:
608
+ raise AgentDirError("At least one context source is required")
609
+ source_by_id = {source["source_id"]: source for source in manifest["sources"]}
610
+ missing = [source_id for source_id in source_ids if source_id not in source_by_id]
611
+ if missing:
612
+ raise AgentDirError("Unknown context source: " + ", ".join(missing))
613
+ return [source_by_id[source_id] for source_id in _unique_source_ids(source_ids)]
614
+
615
+
616
+ def _default_citation_sources(root: str | Path, pack_id: str, manifest: dict[str, Any]) -> list[str]:
617
+ consumed = audit_context_pack(root, pack_id)["consumed_source_ids"]
618
+ if consumed:
619
+ return consumed
620
+ return [source["source_id"] for source in manifest["sources"]]
621
+
622
+
623
+ def _unique_source_ids(source_ids: Any) -> list[str]:
624
+ seen: set[str] = set()
625
+ unique: list[str] = []
626
+ for source_id in source_ids:
627
+ if source_id in seen:
628
+ continue
629
+ seen.add(source_id)
630
+ unique.append(source_id)
631
+ return unique
632
+
633
+
634
+ def _format_source_event_body(
635
+ action: str,
636
+ pack_id: str,
637
+ purpose: str | None,
638
+ sources: list[dict[str, Any]],
639
+ ) -> str:
640
+ lines = [
641
+ f"action={action}",
642
+ f"pack_id={pack_id}",
643
+ ]
644
+ if purpose:
645
+ lines.append(f"purpose={purpose}")
646
+ lines.append(f"sources={len(sources)}")
647
+ lines.append("")
648
+ for source in sources:
649
+ lines.append(
650
+ f"- {source['source_class']} {source['source_id']} "
651
+ f"{source.get('event_type') or ''} {source.get('subject') or ''}".rstrip()
652
+ )
653
+ lines.extend(["", "Context protocol is advisory; source use is recorded for cooperative agents."])
654
+ return "\n".join(lines)