Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ must not fabricate cache invalidation. Core vector, page, and embedding caches a
also rebuildable performance layers, never authorities for revisions, events,
facts, actor knowledge, or snapshots.

Authority history reads are bounded at the service boundary. `EventService.list*`
and `RevisionService.history` accept a validated `limit` plus `offset`, so domain
Authority history reads are bounded at the service boundary. `EventService.list*`,
`MemoryService.search`, `ActorKnowledgeService.search`, `ContinuityService.context`,
and `RevisionService.history` accept a bounded page plus validated `offset`, so domain
MCPs can implement opaque continuation cursors beyond the first 100 records
without loading the complete campaign history into a public tool response.
Continuity advances fact, event, and actor-knowledge candidate windows together and
reports per-stream lookahead plus `next_offset` under `retrieval.pagination`.

## Current domain implementations

Expand Down Expand Up @@ -185,6 +188,7 @@ The package supplies its profile, character schema, module parser, and rules eng
- Snapshots, branches, and revisions are authoritative; vector hits are not.
- A snapshot is a self-contained full checkpoint; only its `recap` is a delta from the parent. Integrity covers the payload, DAG ancestry, and fact/event/actor-knowledge bindings.
- Objective facts use stable `fact_key` identities with branch-scoped revision heads and optimistic revision checks. Subjective actor knowledge remains a separate ledger.
- Fact recency and `updated_at` are derived from the selected branch head. Revising a sibling timeline cannot reorder or retimestamp the current branch. Direct actor-knowledge revisions preserve omitted epistemic, confidence, provenance, cause, and disclosure fields; pass an explicit value (including `source_event_id=None`) to change one. A `ContinuityCommitService` revision binds the newly committed scene event as its provenance while preserving the other omitted fields.
- Prefer `ContinuityCommitService` at scene boundaries so the event, fact upserts, actor-knowledge changes, and optional snapshot commit as one transaction.
- Checkout never silently discards a dirty worktree; save a snapshot before switching branches.
- Writes should use expected revisions and idempotency keys so agent retries cannot duplicate effects.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,12 @@ no-op 写入不得伪造 cache 失效。Core 的向量、页面与 embedding cac
以上三个垂直仓库是当前唯一源码入口。原独立 MCP、Skills、UI 与通用 Module
Generator 仓库已归档,只保留只读历史;新集成不得依赖其分支、发布或文档。

权威历史读取在服务边界保持有界。`EventService.list*` 与
`RevisionService.history` 接受经过校验的 `limit` 和 `offset`,让领域 MCP
权威历史读取在服务边界保持有界。`EventService.list*`、`MemoryService.search`、
`ActorKnowledgeService.search`、`ContinuityService.context` 与 `RevisionService.history`
接受有界分页和经过校验的 `offset`,让领域 MCP
可以用 opaque continuation cursor 翻过前 100 条记录,而不必把完整战役历史加载到公开工具响应。
Continuity 会同步推进事实、事件和角色认知的候选窗口,并在
`retrieval.pagination` 中返回逐流前瞻状态与 `next_offset`。

## MCP 2026 身份基线

Expand Down Expand Up @@ -180,6 +183,7 @@ my_system = "my_package.system:get_system"
- Snapshot、branch 和 revision 是权威连续性;向量命中不是。
- 客观事实使用稳定 `fact_key`,在分支内通过 revision head 演进;修订应携带
`expected_revision_id`。角色的主观知识继续使用独立的 ActorKnowledge ledger。
- 事实的 recency 与 `updated_at` 来自所选分支的 revision head;修改旁支不会重排或重标当前分支。直接调用 ActorKnowledge 修订会保留未提供的认知状态、置信度、来源、成因与披露范围;只有显式传值(包括 `source_event_id=None`)才修改对应字段。通过 `ContinuityCommitService` 修订时,新提交的场景事件会成为来源,其余未提供字段仍保留。
- 场景收尾优先使用 `ContinuityCommitService`,在同一事务中写入事件、事实、
角色认知和可选 Snapshot,避免产生半保存状态。
- Snapshot 在语义上是可独立恢复的全量 checkpoint;`recap` 才是相对父节点的差量摘要。完整性校验同时覆盖 payload、DAG 祖先链以及 fact/event/actor-knowledge bindings。
Expand Down
17 changes: 16 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,26 @@ database and runtime because v30-v32 deliberately have no downgrade protocol.
supply a campaign-stable `fact_key`, normally composed from a subject reference
and predicate. `MemoryRevision` stores lifecycle, disclosure, importance,
valid-time, and source-event evidence; `BranchFactHead` selects the revision
visible in one timeline.
visible in one timeline. Public recency and `updated_at` come from that selected
revision, not from the campaign-wide fact identity, so a sibling branch cannot
reorder or retimestamp the current timeline.

`ActorKnowledge` stores what one live actor believes or remembers. It must not
be replaced by campaign facts or free-form character notes. Forgotten and
superseded heads are excluded from normal recall but remain available for audit.
Revising only the proposition preserves omitted epistemic status, confidence,
source event, cause, and disclosure fields; callers explicitly pass a field to
change it and may explicitly pass `source_event_id=None` to clear provenance.
The scene-boundary `ContinuityCommitService` instead binds its newly committed
event as provenance while preserving the other omitted epistemic fields.

`ContinuityService.context` pages the branch-visible fact, event, and optional
actor-knowledge candidate streams with one validated offset. Visibility and
context-anchor exclusion happen before each bounded window, while shared-budget
selection happens inside it. The response exposes per-stream lookahead and the
next offset under `retrieval.pagination`, allowing domain MCPs to wrap the state
in opaque cursors without duplicating branch, disclosure, scene-projection, or
pinned-module-evidence policy.

At a scene boundary, `ContinuityCommitService` is the preferred write path. It
allocates the event sequence atomically and either commits every requested
Expand Down
85 changes: 61 additions & 24 deletions src/sagasmith_core/continuity.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,20 @@ def context(
scope_id: str = "party",
audience: str = "dm",
limit: int = 8,
offset: int = 0,
budget_chars: int = 12_000,
related_refs: list[str] | None = None,
) -> dict[str, Any]:
if audience not in CONTINUITY_AUDIENCES:
raise ValueError("audience must be 'dm' or 'player'")
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100:
raise ValueError("limit must be an integer between 1 and 100")
if (
isinstance(offset, bool)
or not isinstance(offset, int)
or not 0 <= offset <= 100_000
):
raise ValueError("offset must be an integer between 0 and 100000")
branch = (
self.branches.current(campaign_id)
if branch_id is None
Expand All @@ -79,41 +88,48 @@ def context(
kind=CONTEXT_ANCHOR_KIND,
branch_id=branch.id,
)
facts = [
item
for item in self.facts.search(
campaign_id,
query or " ",
limit=limit + len(anchors),
branch_id=branch.id,
)
if item.kind != CONTEXT_ANCHOR_KIND
][:limit]
events = self.events.list_for_audience(
fact_page = self.facts.search(
campaign_id,
query or " ",
limit=limit + 1,
offset=offset,
branch_id=branch.id,
excluded_kinds={CONTEXT_ANCHOR_KIND},
disclosure_scopes=(
PLAYER_MEMORY_DISCLOSURE_SCOPES if audience == "player" else None
),
)
event_page = self.events.list_for_audience(
campaign_id,
audience=audience,
actor_id=actor_id,
limit=limit,
limit=limit + 1,
offset=offset,
branch_id=branch.id,
)
knowledge = []
knowledge_page = []
if actor_id:
knowledge = self.knowledge.search(
knowledge_page = self.knowledge.search(
campaign_id,
actor_id=actor_id,
query=query or " ",
branch_id=branch.id,
limit=limit,
limit=limit + 1,
offset=offset,
disclosure_scopes=(
PLAYER_OWNED_ACTOR_DISCLOSURE_SCOPES
if audience == "player"
else None
),
)
if audience == "player":
facts = [
item for item in facts if item.disclosure_scope in PLAYER_MEMORY_DISCLOSURE_SCOPES
]
knowledge = [
item
for item in knowledge
if item.disclosure_scope in PLAYER_OWNED_ACTOR_DISCLOSURE_SCOPES
]
facts = fact_page[:limit]
events = event_page[-limit:]
knowledge = knowledge_page[:limit]
stream_has_more = {
"facts": len(fact_page) > limit,
"events": len(event_page) > limit,
"actor_knowledge": len(knowledge_page) > limit,
}
current = self.branches.current(campaign_id)
if branch.id == current.id:
scoped_state = self.modules.current_scene(campaign_id, scope_id=scope_id)
Expand Down Expand Up @@ -160,6 +176,27 @@ def context(
retrieval["strategy"] = "lexical_structured_pinned_module_evidence_v3"
retrieval["active_context_refs"] = sorted(active_refs)
retrieval["pinned_module_evidence_count"] = len(module_evidence)
has_more = any(stream_has_more.values())
retrieval["pagination"] = {
"offset": offset,
"page_limit": limit,
"has_more": has_more,
"next_offset": offset + limit if has_more else None,
"streams": {
"facts": {
"candidate_count": len(facts),
"has_more": stream_has_more["facts"],
},
"events": {
"candidate_count": len(events),
"has_more": stream_has_more["events"],
},
"actor_knowledge": {
"candidate_count": len(knowledge),
"has_more": stream_has_more["actor_knowledge"],
},
},
}
return {
"campaign_id": campaign_id,
"branch": asdict(branch),
Expand Down
16 changes: 12 additions & 4 deletions src/sagasmith_core/continuity_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,19 @@ def _apply_knowledge(
branch_id,
head_snapshot_id,
proposition=self._required_text(data, "proposition"),
epistemic_status=str(data.get("epistemic_status", "known")),
confidence=int(data.get("confidence", 3)),
epistemic_status=(
str(data["epistemic_status"])
if data.get("epistemic_status") is not None
else None
),
confidence=(int(data["confidence"]) if data.get("confidence") is not None else None),
source_event_id=str(source_event_id),
cause=str(data.get("cause", "told_by")),
disclosure_scope=str(data.get("disclosure_scope", "dm")),
cause=str(data["cause"]) if data.get("cause") is not None else None,
disclosure_scope=(
str(data["disclosure_scope"])
if data.get("disclosure_scope") is not None
else None
),
expected_revision_id=data.get("expected_revision_id"),
)

Expand Down
84 changes: 57 additions & 27 deletions src/sagasmith_core/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
INACTIVE_ACTOR_KNOWLEDGE_STATUSES = frozenset({"forgotten", "superseded"})


class _UnsetSourceEvent:
"""Distinguish an omitted source event from an explicit evidence clear."""


_UNSET_SOURCE_EVENT = _UnsetSourceEvent()


@dataclass(frozen=True)
class ActorKnowledgeInfo:
id: str
Expand Down Expand Up @@ -104,18 +111,16 @@ def revise(
knowledge_id: str,
*,
proposition: str,
epistemic_status: str = "known",
confidence: int = 3,
source_event_id: str | None = None,
cause: str = "told_by",
disclosure_scope: str = "dm",
epistemic_status: str | None = None,
confidence: int | None = None,
source_event_id: str | None | _UnsetSourceEvent = _UNSET_SOURCE_EVENT,
cause: str | None = None,
disclosure_scope: str | None = None,
branch_id: str | None = None,
expected_revision_id: str | None = None,
idempotency_key: str | None = None,
idempotency_write: IdempotencyWrite | None = None,
) -> ActorKnowledgeInfo:
self._validate_status(epistemic_status)
self._validate_disclosure_scope(disclosure_scope)
with self.database.transaction() as session:
knowledge = session.get(ActorKnowledge, knowledge_id)
if knowledge is None:
Expand Down Expand Up @@ -221,22 +226,13 @@ def _revise_in_session(
head_snapshot_id: str | None,
*,
proposition: str,
epistemic_status: str,
confidence: int,
source_event_id: str | None,
cause: str,
disclosure_scope: str,
epistemic_status: str | None,
confidence: int | None,
source_event_id: str | None | _UnsetSourceEvent,
cause: str | None,
disclosure_scope: str | None,
expected_revision_id: str | None,
) -> ActorKnowledgeInfo:
self._validate_status(epistemic_status)
self._validate_disclosure_scope(disclosure_scope)
self._validate_event(
session,
source_event_id,
knowledge.campaign_id,
branch_id,
head_snapshot_id,
)
head = session.get(
BranchActorKnowledgeHead,
{"branch_id": branch_id, "knowledge_id": knowledge.id},
Expand All @@ -248,15 +244,40 @@ def _revise_in_session(
f"expected actor-knowledge revision {expected_revision_id}, "
f"current revision is {head.revision_id}"
)
current = session.get(ActorKnowledgeRevision, head.revision_id)
if current is None:
raise LookupError(head.revision_id)
resolved_status = (
current.epistemic_status if epistemic_status is None else epistemic_status
)
resolved_confidence = current.confidence if confidence is None else confidence
resolved_source_event_id = (
current.source_event_id
if isinstance(source_event_id, _UnsetSourceEvent)
else source_event_id
)
resolved_cause = current.cause if cause is None else cause
resolved_disclosure_scope = (
current.disclosure_scope if disclosure_scope is None else disclosure_scope
)
self._validate_status(resolved_status)
self._validate_disclosure_scope(resolved_disclosure_scope)
self._validate_event(
session,
resolved_source_event_id,
knowledge.campaign_id,
branch_id,
head_snapshot_id,
)
revision = self._revision(
knowledge.id,
parent_id=head.revision_id,
proposition=proposition,
epistemic_status=epistemic_status,
confidence=confidence,
source_event_id=source_event_id,
cause=cause,
disclosure_scope=disclosure_scope,
epistemic_status=resolved_status,
confidence=resolved_confidence,
source_event_id=resolved_source_event_id,
cause=resolved_cause,
disclosure_scope=resolved_disclosure_scope,
)
session.add(revision)
session.flush()
Expand Down Expand Up @@ -364,9 +385,18 @@ def search(
query: str,
branch_id: str | None = None,
limit: int = 8,
offset: int = 0,
include_inactive: bool = False,
disclosure_scopes: set[str] | frozenset[str] | None = None,
) -> list[ActorKnowledgeInfo]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
raise ValueError("limit must be an integer between 1 and 500")
if (
isinstance(offset, bool)
or not isinstance(offset, int)
or not 0 <= offset <= 100_000
):
raise ValueError("offset must be an integer between 0 and 100000")
values = self.list(
campaign_id,
actor_id=actor_id,
Expand All @@ -380,7 +410,7 @@ def search(
-lexical_score(query, title=value.knowledge_key, content=value.proposition)
),
)
return ranked[: max(1, min(limit, 100))]
return ranked[offset : offset + limit]

@staticmethod
def _revision(
Expand Down
Loading