strange-loops 0.1.0__tar.gz
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.
- strange_loops-0.1.0/.gitignore +27 -0
- strange_loops-0.1.0/PKG-INFO +20 -0
- strange_loops-0.1.0/README.md +8 -0
- strange_loops-0.1.0/apps/loops/src/loops/__init__.py +0 -0
- strange_loops-0.1.0/apps/loops/src/loops/__main__.py +7 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/__init__.py +1 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/fetch.py +207 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/identity.py +222 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/pop.py +347 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/store.py +125 -0
- strange_loops-0.1.0/apps/loops/src/loops/commands/vertices.py +127 -0
- strange_loops-0.1.0/apps/loops/src/loops/lens_resolver.py +166 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/CLAUDE.md +111 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/__init__.py +1 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/_helpers.py +103 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/compile.py +134 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/fold.py +218 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/gist.py +133 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/pop.py +72 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/run.py +134 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/store.py +326 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/stream.py +177 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/sync.py +141 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/test.py +60 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/validate.py +64 -0
- strange_loops-0.1.0/apps/loops/src/loops/lenses/vertices.py +78 -0
- strange_loops-0.1.0/apps/loops/src/loops/main.py +2586 -0
- strange_loops-0.1.0/apps/loops/src/loops/palette.py +109 -0
- strange_loops-0.1.0/apps/loops/src/loops/pop_store.py +118 -0
- strange_loops-0.1.0/apps/loops/src/loops/tui/__init__.py +5 -0
- strange_loops-0.1.0/apps/loops/src/loops/tui/store_app.py +503 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/__init__.py +103 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/boundary.py +59 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/engine.py +207 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/facet.py +34 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/fact.py +92 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/fold.py +245 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/fold_state.py +106 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/parse.py +638 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/protocol.py +28 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/sequential.py +56 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/source.py +231 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/spec.py +119 -0
- strange_loops-0.1.0/libs/atoms/src/atoms/types.py +145 -0
- strange_loops-0.1.0/libs/engine/src/engine/__init__.py +149 -0
- strange_loops-0.1.0/libs/engine/src/engine/cadence.py +111 -0
- strange_loops-0.1.0/libs/engine/src/engine/compiler.py +994 -0
- strange_loops-0.1.0/libs/engine/src/engine/executor.py +300 -0
- strange_loops-0.1.0/libs/engine/src/engine/file_store.py +128 -0
- strange_loops-0.1.0/libs/engine/src/engine/file_writer.py +38 -0
- strange_loops-0.1.0/libs/engine/src/engine/forward.py +26 -0
- strange_loops-0.1.0/libs/engine/src/engine/lens.py +56 -0
- strange_loops-0.1.0/libs/engine/src/engine/loop.py +155 -0
- strange_loops-0.1.0/libs/engine/src/engine/observer.py +48 -0
- strange_loops-0.1.0/libs/engine/src/engine/peer.py +165 -0
- strange_loops-0.1.0/libs/engine/src/engine/program.py +161 -0
- strange_loops-0.1.0/libs/engine/src/engine/projection.py +98 -0
- strange_loops-0.1.0/libs/engine/src/engine/replay.py +33 -0
- strange_loops-0.1.0/libs/engine/src/engine/source_protocol.py +54 -0
- strange_loops-0.1.0/libs/engine/src/engine/sqlite_store.py +225 -0
- strange_loops-0.1.0/libs/engine/src/engine/store.py +215 -0
- strange_loops-0.1.0/libs/engine/src/engine/store_reader.py +300 -0
- strange_loops-0.1.0/libs/engine/src/engine/stream.py +73 -0
- strange_loops-0.1.0/libs/engine/src/engine/tailer.py +89 -0
- strange_loops-0.1.0/libs/engine/src/engine/tick.py +61 -0
- strange_loops-0.1.0/libs/engine/src/engine/vertex.py +714 -0
- strange_loops-0.1.0/libs/engine/src/engine/vertex_reader.py +1139 -0
- strange_loops-0.1.0/libs/lang/src/lang/__init__.py +141 -0
- strange_loops-0.1.0/libs/lang/src/lang/ast.py +565 -0
- strange_loops-0.1.0/libs/lang/src/lang/errors.py +59 -0
- strange_loops-0.1.0/libs/lang/src/lang/loader.py +796 -0
- strange_loops-0.1.0/libs/lang/src/lang/population.py +462 -0
- strange_loops-0.1.0/libs/lang/src/lang/validator.py +365 -0
- strange_loops-0.1.0/libs/store/src/store/__init__.py +29 -0
- strange_loops-0.1.0/libs/store/src/store/_conn.py +77 -0
- strange_loops-0.1.0/libs/store/src/store/_transport_local.py +68 -0
- strange_loops-0.1.0/libs/store/src/store/compact.py +66 -0
- strange_loops-0.1.0/libs/store/src/store/merge.py +98 -0
- strange_loops-0.1.0/libs/store/src/store/receive.py +85 -0
- strange_loops-0.1.0/libs/store/src/store/slice.py +143 -0
- strange_loops-0.1.0/libs/store/src/store/transport.py +150 -0
- strange_loops-0.1.0/pyproject.toml +57 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
dist/
|
|
5
|
+
build/
|
|
6
|
+
.venv/
|
|
7
|
+
.uv-cache/
|
|
8
|
+
uv.lock
|
|
9
|
+
*.jsonl
|
|
10
|
+
.coverage
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.subtask/
|
|
13
|
+
.tadpole/
|
|
14
|
+
.claude/todos/
|
|
15
|
+
.obsidian/
|
|
16
|
+
.DS_Store
|
|
17
|
+
/data/
|
|
18
|
+
*.db
|
|
19
|
+
*.db-shm
|
|
20
|
+
*.db-wal
|
|
21
|
+
*.gif
|
|
22
|
+
*.tape
|
|
23
|
+
check.vertex
|
|
24
|
+
session.vertex
|
|
25
|
+
!config/**/session.vertex
|
|
26
|
+
!config/**/check.vertex
|
|
27
|
+
.ruff_cache/
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: strange-loops
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A system for focusing attention
|
|
5
|
+
Author-email: Kyle Gruel <kylegruel@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: ckdl>=1.0
|
|
9
|
+
Requires-Dist: painted>=0.1.1
|
|
10
|
+
Requires-Dist: sqlite-ulid
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# strange-loops
|
|
14
|
+
|
|
15
|
+
A system for focusing attention. Observations flow in, accumulate into state,
|
|
16
|
+
boundaries resolve, conclusions flow out. The conclusions re-enter as new
|
|
17
|
+
observations. The loop closes through the observer.
|
|
18
|
+
|
|
19
|
+
# Caveat
|
|
20
|
+
This is AI slop. It exists here because I was asked to share it, not because it is in a state to share and faffing about making it non-public seemed silly.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# strange-loops
|
|
2
|
+
|
|
3
|
+
A system for focusing attention. Observations flow in, accumulate into state,
|
|
4
|
+
boundaries resolve, conclusions flow out. The conclusions re-enter as new
|
|
5
|
+
observations. The loop closes through the observer.
|
|
6
|
+
|
|
7
|
+
# Caveat
|
|
8
|
+
This is AI slop. It exists here because I was asked to share it, not because it is in a state to share and faffing about making it non-public seemed silly.
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Store commands — data fetch layer."""
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Data retrieval — fold (collapsed state) and stream (event history).
|
|
2
|
+
|
|
3
|
+
Supports key drill-down via ``kind/key`` syntax on the ``--kind`` flag:
|
|
4
|
+
``--kind thread/fold-state-types`` filters to the single folded item
|
|
5
|
+
(fold) or facts matching the key field value (stream).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from atoms import FoldItem, FoldState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_duration(s: str) -> float:
|
|
20
|
+
"""Parse duration string like '7d', '24h', '1h' to seconds."""
|
|
21
|
+
m = re.match(r"^(\d+)([dhms])$", s)
|
|
22
|
+
if not m:
|
|
23
|
+
raise ValueError(f"Invalid duration: {s!r} (expected e.g. '7d', '24h', '1h')")
|
|
24
|
+
value = int(m.group(1))
|
|
25
|
+
unit = m.group(2)
|
|
26
|
+
multipliers = {"d": 86400, "h": 3600, "m": 60, "s": 1}
|
|
27
|
+
return value * multipliers[unit]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _split_kind_key(kind: str | None) -> tuple[str | None, str | None]:
|
|
31
|
+
"""Split ``kind/key`` into (kind, key). Plain kind returns (kind, None)."""
|
|
32
|
+
if kind is None:
|
|
33
|
+
return None, None
|
|
34
|
+
if "/" in kind:
|
|
35
|
+
k, v = kind.split("/", 1)
|
|
36
|
+
return k, v
|
|
37
|
+
return kind, None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_key_field(vertex_path: Path, kind: str) -> str | None:
|
|
41
|
+
"""Look up the key field for a kind from the vertex's fold declarations."""
|
|
42
|
+
from lang import parse_vertex_file
|
|
43
|
+
from lang.ast import FoldBy
|
|
44
|
+
|
|
45
|
+
ast = parse_vertex_file(vertex_path)
|
|
46
|
+
loop_def = ast.loops.get(kind)
|
|
47
|
+
if loop_def and loop_def.folds:
|
|
48
|
+
fold_decl = loop_def.folds[0]
|
|
49
|
+
if isinstance(fold_decl.op, FoldBy):
|
|
50
|
+
return fold_decl.op.key_field
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_fold(
|
|
55
|
+
vertex_path: Path,
|
|
56
|
+
kind: str | None = None,
|
|
57
|
+
observer: str | None = None,
|
|
58
|
+
) -> "FoldState":
|
|
59
|
+
"""Fetch fold state, with optional key drill-down.
|
|
60
|
+
|
|
61
|
+
Supports ``kind/key`` syntax: ``thread/fold-state-types`` filters
|
|
62
|
+
to the single item whose key field matches. The fold section is
|
|
63
|
+
preserved (one item instead of many) so lenses render normally.
|
|
64
|
+
"""
|
|
65
|
+
from atoms import FoldSection, FoldState
|
|
66
|
+
from engine import vertex_fold
|
|
67
|
+
|
|
68
|
+
kind_filter, key_filter = _split_kind_key(kind)
|
|
69
|
+
state = vertex_fold(vertex_path, observer=observer, kind=kind_filter)
|
|
70
|
+
|
|
71
|
+
if key_filter is None:
|
|
72
|
+
return state
|
|
73
|
+
|
|
74
|
+
# Filter sections to items matching the key value
|
|
75
|
+
filtered: list[FoldSection] = []
|
|
76
|
+
for section in state.sections:
|
|
77
|
+
if section.kind != kind_filter:
|
|
78
|
+
continue
|
|
79
|
+
matches = tuple(
|
|
80
|
+
item for item in section.items
|
|
81
|
+
if _item_matches_key(item, section.key_field, key_filter)
|
|
82
|
+
)
|
|
83
|
+
if matches:
|
|
84
|
+
filtered.append(FoldSection(
|
|
85
|
+
kind=section.kind,
|
|
86
|
+
items=matches,
|
|
87
|
+
sections=section.sections,
|
|
88
|
+
fold_type=section.fold_type,
|
|
89
|
+
key_field=section.key_field,
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
return FoldState(sections=tuple(filtered), vertex=state.vertex)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _item_matches_key(item: "FoldItem", key_field: str | None, key: str) -> bool:
|
|
96
|
+
"""Check if a fold item matches a key value.
|
|
97
|
+
|
|
98
|
+
Tries key_field first, then common label fields. Case-insensitive.
|
|
99
|
+
"""
|
|
100
|
+
candidates = [key_field] if key_field else []
|
|
101
|
+
candidates.extend(["topic", "name", "title", "summary"])
|
|
102
|
+
|
|
103
|
+
for field in candidates:
|
|
104
|
+
if field and field in item.payload:
|
|
105
|
+
val = str(item.payload[field])
|
|
106
|
+
if val.lower() == key.lower():
|
|
107
|
+
return True
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fetch_stream(
|
|
112
|
+
vertex_path: Path,
|
|
113
|
+
*,
|
|
114
|
+
query: str | None = None,
|
|
115
|
+
kind: str | None = None,
|
|
116
|
+
since: str | None = None,
|
|
117
|
+
observer: str | None = None,
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Fetch event stream with three orthogonal filters.
|
|
120
|
+
|
|
121
|
+
Unifies log + search into a single fetch. When *query* is provided,
|
|
122
|
+
uses FTS5 search; otherwise returns raw facts in reverse-chrono order.
|
|
123
|
+
|
|
124
|
+
Supports ``kind/key`` drill-down: ``--kind thread/fold-state-types``
|
|
125
|
+
returns only facts whose key field payload matches. When drilling down,
|
|
126
|
+
time window defaults to all history (not 7d).
|
|
127
|
+
|
|
128
|
+
Returns ``{"facts": list[dict], "fold_meta": dict, "vertex": str}``.
|
|
129
|
+
"""
|
|
130
|
+
from engine import vertex_facts, vertex_search
|
|
131
|
+
from lang import parse_vertex_file
|
|
132
|
+
from lang.ast import FoldBy
|
|
133
|
+
|
|
134
|
+
kind_filter, key_filter = _split_kind_key(kind)
|
|
135
|
+
|
|
136
|
+
# When drilling into a specific item, default to all history
|
|
137
|
+
default_since = "7d" if key_filter is None else "3650d"
|
|
138
|
+
since_secs = _parse_duration(since or default_since)
|
|
139
|
+
now = datetime.now(timezone.utc)
|
|
140
|
+
since_ts = (now - timedelta(seconds=since_secs)).timestamp()
|
|
141
|
+
|
|
142
|
+
if query:
|
|
143
|
+
facts = vertex_search(
|
|
144
|
+
vertex_path, query, kind=kind_filter, since=since_ts, limit=100,
|
|
145
|
+
observer=observer,
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
facts = vertex_facts(
|
|
149
|
+
vertex_path, since_ts, now.timestamp(), kind=kind_filter,
|
|
150
|
+
observer=observer,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Key drill-down: filter facts by payload key field value
|
|
154
|
+
if key_filter is not None:
|
|
155
|
+
key_field = _get_key_field(vertex_path, kind_filter) if kind_filter else None
|
|
156
|
+
facts = [
|
|
157
|
+
f for f in facts
|
|
158
|
+
if _fact_matches_key(f, key_field, key_filter)
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
facts.sort(key=lambda f: f["ts"], reverse=True)
|
|
162
|
+
|
|
163
|
+
# Normalize timestamps for JSON serialization
|
|
164
|
+
for f in facts:
|
|
165
|
+
if isinstance(f["ts"], datetime):
|
|
166
|
+
f["ts"] = f["ts"].isoformat()
|
|
167
|
+
|
|
168
|
+
# Get fold declarations for rendering hints
|
|
169
|
+
ast = parse_vertex_file(vertex_path)
|
|
170
|
+
fold_meta: dict[str, dict] = {}
|
|
171
|
+
for k, loop_def in ast.loops.items():
|
|
172
|
+
key_field = None
|
|
173
|
+
if loop_def.folds:
|
|
174
|
+
fold_decl = loop_def.folds[0]
|
|
175
|
+
if isinstance(fold_decl.op, FoldBy):
|
|
176
|
+
key_field = fold_decl.op.key_field
|
|
177
|
+
fold_meta[k] = {"key_field": key_field}
|
|
178
|
+
|
|
179
|
+
return {"facts": facts, "fold_meta": fold_meta, "vertex": ast.name}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _fact_matches_key(fact: dict, key_field: str | None, key: str) -> bool:
|
|
183
|
+
"""Check if a raw fact's payload matches a key value."""
|
|
184
|
+
payload = fact.get("payload", {})
|
|
185
|
+
candidates = [key_field] if key_field else []
|
|
186
|
+
candidates.extend(["topic", "name", "title", "summary"])
|
|
187
|
+
|
|
188
|
+
for field in candidates:
|
|
189
|
+
if field and field in payload:
|
|
190
|
+
val = str(payload[field])
|
|
191
|
+
if val.lower() == key.lower():
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fetch_fact_by_id(
|
|
197
|
+
vertex_path: Path,
|
|
198
|
+
fact_id: str,
|
|
199
|
+
) -> dict | None:
|
|
200
|
+
"""Fetch a single fact by ID or ID prefix.
|
|
201
|
+
|
|
202
|
+
Returns the full fact dict with id, kind, ts, observer, origin, payload.
|
|
203
|
+
Returns None if not found. Raises ValueError on ambiguous prefix.
|
|
204
|
+
"""
|
|
205
|
+
from engine import vertex_fact_by_id
|
|
206
|
+
|
|
207
|
+
return vertex_fact_by_id(vertex_path, fact_id)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Observer identity — who you are, workspace root, emit validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _loops_home() -> Path:
|
|
10
|
+
"""Resolve the loops config directory (delegates to main.loops_home)."""
|
|
11
|
+
from loops.main import loops_home
|
|
12
|
+
|
|
13
|
+
return loops_home()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_workspace_root(start: Path | None = None) -> Path | None:
|
|
17
|
+
"""Find .vertex workspace root walking up from start (default: cwd).
|
|
18
|
+
|
|
19
|
+
1. .loops/.vertex in start or ancestor dirs
|
|
20
|
+
2. ~/.config/loops/.vertex (global)
|
|
21
|
+
Returns None if not found.
|
|
22
|
+
"""
|
|
23
|
+
current = (start or Path.cwd()).resolve()
|
|
24
|
+
# Walk up from start
|
|
25
|
+
for d in [current, *current.parents]:
|
|
26
|
+
candidate = d / ".loops" / ".vertex"
|
|
27
|
+
if candidate.exists():
|
|
28
|
+
return candidate
|
|
29
|
+
# Global fallback
|
|
30
|
+
home = _loops_home()
|
|
31
|
+
global_root = home / ".vertex"
|
|
32
|
+
if global_root.exists():
|
|
33
|
+
return global_root
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_observers(vertex_path: Path) -> tuple:
|
|
38
|
+
"""Parse a .vertex file and return its observers tuple (or empty)."""
|
|
39
|
+
from lang import parse_vertex_file
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
ast = parse_vertex_file(vertex_path)
|
|
43
|
+
return ast.observers or ()
|
|
44
|
+
except Exception:
|
|
45
|
+
return ()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_observer(explicit: str | None = None, start: Path | None = None) -> str:
|
|
49
|
+
"""Resolve observer identity.
|
|
50
|
+
|
|
51
|
+
Priority chain:
|
|
52
|
+
1. explicit (from --observer flag)
|
|
53
|
+
2. LOOPS_OBSERVER env var
|
|
54
|
+
3. Project .vertex observers block (walking up from start)
|
|
55
|
+
4. Global .vertex observers block
|
|
56
|
+
Returns "" if nothing resolves.
|
|
57
|
+
"""
|
|
58
|
+
# 1. Explicit flag
|
|
59
|
+
if explicit is not None:
|
|
60
|
+
return explicit
|
|
61
|
+
|
|
62
|
+
# 2. Env var
|
|
63
|
+
env = os.environ.get("LOOPS_OBSERVER")
|
|
64
|
+
if env is not None:
|
|
65
|
+
return env
|
|
66
|
+
|
|
67
|
+
# 3. Project-level .vertex (walk up)
|
|
68
|
+
current = (start or Path.cwd()).resolve()
|
|
69
|
+
for d in [current, *current.parents]:
|
|
70
|
+
candidate = d / ".loops" / ".vertex"
|
|
71
|
+
if candidate.exists():
|
|
72
|
+
observers = _read_observers(candidate)
|
|
73
|
+
if len(observers) == 1:
|
|
74
|
+
return observers[0].name
|
|
75
|
+
if observers:
|
|
76
|
+
# Multiple observers declared, can't auto-pick
|
|
77
|
+
return ""
|
|
78
|
+
break # found .vertex but no observers — fall through to global
|
|
79
|
+
|
|
80
|
+
# 4. Global .vertex
|
|
81
|
+
home = _loops_home()
|
|
82
|
+
global_path = home / ".vertex"
|
|
83
|
+
if global_path.exists():
|
|
84
|
+
observers = _read_observers(global_path)
|
|
85
|
+
if len(observers) == 1:
|
|
86
|
+
return observers[0].name
|
|
87
|
+
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _collect_combine_observers(vertex_path: Path) -> list:
|
|
92
|
+
"""Collect observer declarations from combine/discover source vertices.
|
|
93
|
+
|
|
94
|
+
For aggregation vertices, follows the combine chain to source vertices
|
|
95
|
+
and collects their observer declarations. Same pattern as combine
|
|
96
|
+
auto-inherit for fold specs — the aggregation vertex accepts the
|
|
97
|
+
same observers as its sources.
|
|
98
|
+
"""
|
|
99
|
+
from lang import parse_vertex_file, resolve_vertex
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
ast = parse_vertex_file(vertex_path)
|
|
103
|
+
except Exception:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
observers: list = []
|
|
107
|
+
home = _loops_home()
|
|
108
|
+
|
|
109
|
+
if ast.combine is not None:
|
|
110
|
+
for entry in ast.combine:
|
|
111
|
+
vpath = resolve_vertex(entry.name, home)
|
|
112
|
+
if not vpath.is_absolute():
|
|
113
|
+
vpath = (vertex_path.parent / vpath).resolve()
|
|
114
|
+
if vpath.exists():
|
|
115
|
+
observers.extend(_read_observers(vpath))
|
|
116
|
+
# Also check the source vertex's workspace root
|
|
117
|
+
for d in [vpath.parent, *vpath.parent.parents]:
|
|
118
|
+
candidate = d / ".loops" / ".vertex"
|
|
119
|
+
if candidate.exists() and candidate.resolve() != vpath.resolve():
|
|
120
|
+
observers.extend(_read_observers(candidate))
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
elif ast.discover is not None:
|
|
124
|
+
base_dir = vertex_path.parent
|
|
125
|
+
for match in sorted(base_dir.glob(ast.discover)):
|
|
126
|
+
if match.suffix != ".vertex" or match.resolve() == vertex_path.resolve():
|
|
127
|
+
continue
|
|
128
|
+
observers.extend(_read_observers(match))
|
|
129
|
+
|
|
130
|
+
return observers
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_emit(vertex_path: Path, observer: str, kind: str) -> str | None:
|
|
134
|
+
"""Validate observer + kind against vertex declaration chain.
|
|
135
|
+
|
|
136
|
+
Returns error message if rejected, None if allowed.
|
|
137
|
+
Checks:
|
|
138
|
+
1. Observer is declared in the vertex itself, .vertex chain, or
|
|
139
|
+
combine/discover source vertices (cascade)
|
|
140
|
+
2. Kind is in observer's grant.potential (if grant is declared)
|
|
141
|
+
"""
|
|
142
|
+
if not observer:
|
|
143
|
+
return None # No observer to validate
|
|
144
|
+
|
|
145
|
+
# Check the vertex file itself first
|
|
146
|
+
vertex_observers = _read_observers(vertex_path)
|
|
147
|
+
|
|
148
|
+
# Collect observers from project-level .vertex
|
|
149
|
+
project_observers: tuple = ()
|
|
150
|
+
project_root = vertex_path.parent
|
|
151
|
+
for d in [project_root, *project_root.parents]:
|
|
152
|
+
candidate = d / ".loops" / ".vertex"
|
|
153
|
+
if candidate.exists() and candidate.resolve() != vertex_path.resolve():
|
|
154
|
+
project_observers = _read_observers(candidate)
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
# Collect observers from global .vertex
|
|
158
|
+
home = _loops_home()
|
|
159
|
+
global_observers: tuple = ()
|
|
160
|
+
global_path = home / ".vertex"
|
|
161
|
+
if global_path.exists() and global_path.resolve() != vertex_path.resolve():
|
|
162
|
+
global_observers = _read_observers(global_path)
|
|
163
|
+
|
|
164
|
+
# Collect observers from combine/discover source vertices (cascade)
|
|
165
|
+
combine_observers = _collect_combine_observers(vertex_path)
|
|
166
|
+
|
|
167
|
+
all_observers = (
|
|
168
|
+
list(vertex_observers)
|
|
169
|
+
+ list(project_observers)
|
|
170
|
+
+ list(global_observers)
|
|
171
|
+
+ combine_observers
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# No observers declared anywhere -> no validation (open system)
|
|
175
|
+
if not all_observers:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Find the observer declaration (supports namespaced observers)
|
|
179
|
+
from engine.observer import observer_matches
|
|
180
|
+
|
|
181
|
+
decl = None
|
|
182
|
+
for obs in all_observers:
|
|
183
|
+
if observer_matches(obs.name, observer):
|
|
184
|
+
decl = obs
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if decl is None:
|
|
188
|
+
names = sorted({o.name for o in all_observers})
|
|
189
|
+
return f"Observer {observer!r} not declared. Known: {', '.join(names)}"
|
|
190
|
+
|
|
191
|
+
# Check grant.potential if declared
|
|
192
|
+
if decl.grant is not None and kind not in decl.grant.potential:
|
|
193
|
+
allowed = sorted(decl.grant.potential)
|
|
194
|
+
return f"Observer {observer!r} cannot emit kind {kind!r}. Allowed: {', '.join(allowed)}"
|
|
195
|
+
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def resolve_local_vertex(start: Path | None = None) -> Path:
|
|
200
|
+
"""Find a vertex file via workspace root walk-up or session fallback.
|
|
201
|
+
|
|
202
|
+
Resolution order:
|
|
203
|
+
1. Local vertex in cwd (*.vertex)
|
|
204
|
+
2. LOOPS_HOME/session/session.vertex
|
|
205
|
+
|
|
206
|
+
Raises FileNotFoundError if neither found.
|
|
207
|
+
"""
|
|
208
|
+
from loops.main import _find_local_vertex
|
|
209
|
+
|
|
210
|
+
# 1. Local vertex in cwd
|
|
211
|
+
local = _find_local_vertex()
|
|
212
|
+
if local is not None:
|
|
213
|
+
return local
|
|
214
|
+
|
|
215
|
+
# 2. LOOPS_HOME session fallback
|
|
216
|
+
session_vertex = _loops_home() / "session" / "session.vertex"
|
|
217
|
+
if session_vertex.exists():
|
|
218
|
+
return session_vertex
|
|
219
|
+
|
|
220
|
+
raise FileNotFoundError(
|
|
221
|
+
"No vertex found. Run 'loops init --template session' or 'loops emit <kind> ...' first."
|
|
222
|
+
)
|