Skip to content

Commit e1220f9

Browse files
committed
feat(timeline): kb.timeline — entity chronological trajectory (#313)
read_entity returns an entity's claims as a flat set; neighbors expands graph adjacency. neither answers "what did the kb learn about this entity, in what order?". kb.timeline orders an entity's approved claims and relations along a time axis, oldest first. - order=effective uses the artifact's own created_at (accrual time); order=decided recovers approval time from the audit log's approve events (object_ids[1] -> created_at), falling back to created_at for artifacts written outside the proposal path. - since/until/types/limit filters; limit keeps the most recent n while preserving chronological order. - superseded/archived claims still appear, flagged by current status; relations carry status=null; pending proposals never appear. pure read: no propose_*, no approve, no write path is reachable — a viewport over already-reviewed artifacts. all ordering logic lives in a new timeline.py, not storage.py. registered at all four surfaces (mcp/jsonl/capabilities/cli); attaches _meta.vouch_salience when a session_id is passed, per the per-tool convention.
1 parent 2fb255a commit e1220f9

7 files changed

Lines changed: 544 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ All notable changes to vouch are documented here. Format follows
119119
successor (pages still require an explicit `new_id` — they have no
120120
successor pointer). Read-only throughout; still no writes, proposals, or
121121
audit events (#327).
122+
- `kb.timeline` / `vouch timeline <entity>` — a read-only chronological
123+
trajectory of an entity's approved claims and relations (#313). orders them
124+
along a time axis oldest-first: `--order effective` uses artifact `created_at`,
125+
`--order decided` recovers approval time from the audit log. `--since` /
126+
`--until` / `--types` / `--limit` filters; `--json` for the machine shape.
127+
superseded/archived claims still appear, flagged by current status; relations
128+
carry `status = null`; pending proposals never appear. pure read — no write
129+
path is reachable from it. registered at all four surfaces (mcp/jsonl/
130+
capabilities/cli) and attaches `_meta.vouch_salience` when a `session_id` is
131+
passed.
122132
- auto-capture: claude code sessions are harvested via hooks and filed as a
123133
single pending session-summary proposal for human approval. a `PostToolUse`
124134
hook (`vouch capture observe`) appends compact tool-use observations to an

src/vouch/capabilities.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"kb.digest",
3333
"kb.search",
3434
"kb.neighbors",
35+
"kb.timeline",
3536
"kb.context",
3637
"kb.synthesize",
3738
"kb.read_page",

src/vouch/cli.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from . import stats as stats_mod
4343
from . import sync as sync_mod
4444
from . import synthesize as synth
45+
from . import timeline as timeline_mod
4546
from . import trust as trust_mod
4647
from . import vault_sync as vault_sync_mod
4748
from . import verify as verify_mod
@@ -816,6 +817,87 @@ def pages_cmd(
816817
click.echo("no matching pages")
817818

818819

820+
# --- timeline -------------------------------------------------------------
821+
822+
823+
@cli.command()
824+
@click.argument("entity_id")
825+
@click.option(
826+
"--order",
827+
type=click.Choice(["effective", "decided"]),
828+
default="effective",
829+
show_default=True,
830+
help="Time axis: `effective` (artifact created_at) or `decided` (approval "
831+
"time from the audit log).",
832+
)
833+
@click.option(
834+
"--since", default=None, help="Lower bound (iso date/datetime or a duration like 30d)."
835+
)
836+
@click.option("--until", default=None, help="Upper bound (same formats as --since).")
837+
@click.option(
838+
"--types",
839+
default=None,
840+
help="Comma-separated filter on claim type (fact,decision,…), relation type, "
841+
"or the literal `claim` / `relation`.",
842+
)
843+
@click.option("--limit", default=None, type=int, help="Keep only the most recent N entries.")
844+
@click.option("--json", "as_json", is_flag=True, help="Emit the machine shape instead of a table.")
845+
def timeline(
846+
entity_id: str,
847+
order: str,
848+
since: str | None,
849+
until: str | None,
850+
types: str | None,
851+
limit: int | None,
852+
as_json: bool,
853+
) -> None:
854+
"""Chronological trajectory of an entity's claims and relations (#313).
855+
856+
\b
857+
Orders an entity's approved claims + relations along a time axis, oldest
858+
first. Read-only — pending proposals never appear.
859+
860+
\b
861+
Examples:
862+
vouch timeline person:alice-example
863+
vouch timeline repo:acme --order decided --since 2026-01-01
864+
vouch timeline concept:auth --types decision,fact --json
865+
"""
866+
store = _load_store()
867+
type_list = [t.strip() for t in types.split(",")] if types else None
868+
try:
869+
result = timeline_mod.build_timeline(
870+
store,
871+
entity_id,
872+
since=metrics_mod.parse_since(since),
873+
until=metrics_mod.parse_since(until),
874+
order=order,
875+
types=type_list,
876+
limit=limit,
877+
)
878+
except timeline_mod.TimelineError as e:
879+
raise click.ClickException(str(e)) from e
880+
except (ArtifactNotFoundError, KeyError) as e:
881+
raise click.ClickException(f"entity not found: {entity_id}") from e
882+
883+
if as_json:
884+
_emit_json(result)
885+
return
886+
887+
ent = result["entity"]
888+
click.echo(f"timeline: {ent['name']} [{ent['type']}] ({ent['id']})")
889+
click.echo(f" order={result['order']} {result['count']} of {result['total']} entries")
890+
click.echo("")
891+
if not result["entries"]:
892+
click.echo(" (no claims or relations for this entity in range)")
893+
return
894+
for entry in result["entries"]:
895+
when = (entry["when"] or "—")[:19]
896+
status = f" <{entry['status']}>" if entry["status"] else ""
897+
click.echo(f" {when} {entry['kind']:<8} {entry['id']}{status}")
898+
click.echo(f" {entry['summary'][:100]}")
899+
900+
819901
# --- proposals ------------------------------------------------------------
820902

821903

src/vouch/jsonl_server.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,26 @@ def _h_neighbors(p: dict) -> dict:
198198
)
199199

200200

201+
def _h_timeline(p: dict) -> dict:
202+
from .metrics import parse_since
203+
from .timeline import build_timeline
204+
205+
store = _store()
206+
cfg = _load_cfg(store)
207+
session_id = p.get("session_id")
208+
limit = p.get("limit")
209+
result = build_timeline(
210+
store,
211+
p["entity_id"],
212+
since=parse_since(p.get("since")),
213+
until=parse_since(p.get("until")),
214+
order=p.get("order", "effective"),
215+
types=p.get("types"),
216+
limit=int(limit) if limit is not None else None,
217+
)
218+
return salience_mod.attach_salience(result, store, session_id, cfg)
219+
220+
201221
def _h_context(p: dict) -> dict:
202222
store = _store()
203223
query = p["task"]
@@ -733,6 +753,7 @@ def _h_propose_theme(p: dict) -> dict:
733753
"kb.digest": _h_digest,
734754
"kb.search": _h_search,
735755
"kb.neighbors": _h_neighbors,
756+
"kb.timeline": _h_timeline,
736757
"kb.context": _h_context,
737758
"kb.synthesize": _h_synthesize,
738759
"kb.read_page": _h_read_page,

src/vouch/server.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,39 @@ def kb_neighbors(
227227
raise ValueError(str(e)) from e
228228

229229

230+
@mcp.tool()
231+
def kb_timeline(
232+
entity_id: str,
233+
order: str = "effective",
234+
since: str | None = None,
235+
until: str | None = None,
236+
types: list[str] | None = None,
237+
limit: int | None = None,
238+
session_id: str | None = None,
239+
) -> dict[str, Any]:
240+
"""Chronological trajectory of an entity's approved claims and relations.
241+
242+
``order`` is ``effective`` (artifact ``created_at``) or ``decided`` (approval
243+
time recovered from the audit log). Read-only; pending proposals never
244+
appear. Attaches ``_meta.vouch_salience`` when a ``session_id`` is given.
245+
"""
246+
from .metrics import parse_since
247+
from .timeline import TimelineError, build_timeline
248+
249+
store = _store()
250+
try:
251+
result = build_timeline(
252+
store, entity_id,
253+
since=parse_since(since), until=parse_since(until),
254+
order=order, types=types, limit=limit,
255+
)
256+
except ArtifactNotFoundError as e:
257+
raise ValueError(str(e)) from e
258+
except TimelineError as e:
259+
raise ValueError(str(e)) from e
260+
return salience_mod.attach_salience(result, store, session_id, _load_cfg(store))
261+
262+
230263
@mcp.tool()
231264
def kb_context(
232265
task: str,

src/vouch/timeline.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""``kb.timeline`` — chronological trajectory of an entity (vouchdev/vouch#313).
2+
3+
``kb.read_entity`` returns an entity and its claims as a flat set; ``kb.neighbors``
4+
expands graph adjacency at a point in time. Neither answers "what did the KB
5+
learn about this entity, *in what order*?" — a trajectory. The raw material is
6+
already on disk: ``Claim`` and ``Relation`` carry ``created_at``, and decision
7+
time is recoverable from the append-only ``audit.log.jsonl``. This orders an
8+
entity's approved claims and relations along that time axis.
9+
10+
Pure read. It never proposes, approves, or writes — a viewport over
11+
already-reviewed artifacts, exactly like ``read_entity`` / ``neighbors``. Only
12+
*approved* durable artifacts are read (``list_claims`` / ``list_relations``);
13+
pending proposals never appear. All ordering logic lives here, not in
14+
``storage.py``, which stays pure I/O.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from datetime import UTC, datetime
20+
from typing import Any
21+
22+
from .audit import read_events
23+
from .metrics import _APPROVE_RE
24+
from .models import Claim, Relation
25+
from .storage import KBStore
26+
27+
# Ordering axes. ``effective`` orders by the artifact's own ``created_at`` (when
28+
# the fact entered the KB); ``decided`` orders by the moment the proposal that
29+
# produced it was approved, recovered from the audit log.
30+
ORDER_EFFECTIVE = "effective"
31+
ORDER_DECIDED = "decided"
32+
ORDERS = (ORDER_EFFECTIVE, ORDER_DECIDED)
33+
34+
35+
class TimelineError(ValueError):
36+
"""User-visible bad input (e.g. an unknown ``order``)."""
37+
38+
39+
def _as_utc(dt: datetime | None) -> datetime | None:
40+
if dt is None:
41+
return None
42+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
43+
44+
45+
def _iso(dt: datetime | None) -> str | None:
46+
return dt.isoformat() if dt is not None else None
47+
48+
49+
def _decided_map(store: KBStore) -> dict[str, datetime]:
50+
"""artifact id -> approval time, from the authoritative audit stream.
51+
52+
``proposals.approve`` logs an approve event with ``object_ids = [proposal_id,
53+
result_id]``; the event's ``created_at`` is the decision time. Artifacts
54+
written outside the proposal path (rare, e.g. a seeded starter KB) simply
55+
have no entry and fall back to their ``created_at``.
56+
"""
57+
out: dict[str, datetime] = {}
58+
for ev in read_events(store.kb_dir):
59+
if _APPROVE_RE.match(ev.event) and len(ev.object_ids) >= 2:
60+
ts = _as_utc(ev.created_at)
61+
if ts is not None:
62+
out[ev.object_ids[1]] = ts
63+
return out
64+
65+
66+
def _claim_summary(c: Claim) -> str:
67+
return str(c.text).strip()[:120] or "—"
68+
69+
70+
def _relation_summary(r: Relation) -> str:
71+
return f"{r.source} {r.relation.value} {r.target}"
72+
73+
74+
def build_timeline(
75+
store: KBStore,
76+
entity_id: str,
77+
*,
78+
since: datetime | None = None,
79+
until: datetime | None = None,
80+
order: str = ORDER_EFFECTIVE,
81+
types: list[str] | None = None,
82+
limit: int | None = None,
83+
) -> dict[str, Any]:
84+
"""Order an entity's approved claims + relations along a time axis.
85+
86+
Entries are ``{when, kind, id, summary, status}``, oldest first
87+
(most-recent-last). ``status`` is the claim's current :class:`ClaimStatus`
88+
(a superseded/archived claim still appears, flagged); relations have no
89+
status, so it is ``None``. When ``limit`` is set, the most recent ``limit``
90+
entries are kept, still in chronological order. Raises
91+
:class:`~vouch.storage.ArtifactNotFoundError` if the entity does not exist.
92+
"""
93+
if order not in ORDERS:
94+
raise TimelineError(f"order must be one of {ORDERS}, got {order!r}")
95+
if limit is not None and limit < 0:
96+
raise TimelineError("limit must be >= 0")
97+
since = _as_utc(since)
98+
until = _as_utc(until)
99+
100+
entity = store.get_entity(entity_id) # raises ArtifactNotFoundError
101+
102+
want = {t.strip() for t in types if t.strip()} if types else None
103+
decided = _decided_map(store) if order == ORDER_DECIDED else {}
104+
105+
def when_for(artifact_id: str, created: datetime | None) -> datetime | None:
106+
eff = _as_utc(created)
107+
if order == ORDER_DECIDED:
108+
return decided.get(artifact_id) or eff
109+
return eff
110+
111+
rows: list[dict[str, Any]] = []
112+
113+
for c in store.list_claims():
114+
if entity_id not in c.entities:
115+
continue
116+
# type filter: a claim matches on its ClaimType, or the generic "claim".
117+
if want is not None and c.type.value not in want and "claim" not in want:
118+
continue
119+
rows.append(
120+
{
121+
"_when": when_for(c.id, c.created_at),
122+
"kind": "claim",
123+
"id": c.id,
124+
"summary": _claim_summary(c),
125+
"status": c.status.value,
126+
}
127+
)
128+
129+
for r in store.list_relations():
130+
if entity_id not in (r.source, r.target):
131+
continue
132+
# type filter: a relation matches on its RelationType, or "relation".
133+
if want is not None and r.relation.value not in want and "relation" not in want:
134+
continue
135+
rows.append(
136+
{
137+
"_when": when_for(r.id, r.created_at),
138+
"kind": "relation",
139+
"id": r.id,
140+
"summary": _relation_summary(r),
141+
"status": None,
142+
}
143+
)
144+
145+
# window filter on the chosen timestamp
146+
if since is not None:
147+
rows = [e for e in rows if e["_when"] is not None and e["_when"] >= since]
148+
if until is not None:
149+
rows = [e for e in rows if e["_when"] is not None and e["_when"] <= until]
150+
151+
# oldest first; id tie-break for deterministic output on identical stamps.
152+
# entries with no recoverable timestamp sort to the front (epoch).
153+
_epoch = datetime(1970, 1, 1, tzinfo=UTC)
154+
rows.sort(key=lambda e: (e["_when"] or _epoch, e["id"]))
155+
156+
total = len(rows)
157+
if limit is not None and limit < total:
158+
rows = rows[total - limit :] # keep the most recent `limit`, still chronological
159+
160+
entries = [
161+
{"when": _iso(e["_when"]), "kind": e["kind"], "id": e["id"],
162+
"summary": e["summary"], "status": e["status"]}
163+
for e in rows
164+
]
165+
166+
return {
167+
"entity": {"id": entity.id, "name": entity.name, "type": entity.type.value},
168+
"order": order,
169+
"count": len(entries),
170+
"total": total,
171+
"entries": entries,
172+
}

0 commit comments

Comments
 (0)