PlanOpticon

feat: add PlanOpticonExchange format and companion CLI REPL PlanOpticonExchange: canonical JSON interchange format with Pydantic models (ProjectMeta, ArtifactMeta), JSON schema export, KG import/export, and merge with deduplication. CLI: planopticon export exchange, planopticon kg from-exchange. Companion REPL: interactive planning assistant with workspace auto-discovery, 13 built-in slash commands (no LLM needed), and LLM chat mode. Requires --interactive or --chat flag to launch. 781 tests passing.

lmata 2026-03-07 23:30 trunk
Commit 3d5f08e1507c6b19190cd4b9ba4b721536165cc400d6a5ca37c775f0155f3017
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -230,5 +230,23 @@
230230
runner = CliRunner()
231231
result = runner.invoke(cli, ["auth", "--help"])
232232
assert result.exit_code == 0
233233
assert "google" in result.output
234234
assert "dropbox" in result.output
235
+
236
+
237
+class TestCompanionHelp:
238
+ def test_help(self):
239
+ runner = CliRunner()
240
+ result = runner.invoke(cli, ["companion", "--help"])
241
+ assert result.exit_code == 0
242
+ assert "--kb" in result.output
243
+ assert "--provider" in result.output
244
+ assert "--chat-model" in result.output
245
+ assert "--interactive" in result.output
246
+ assert "--chat" in result.output
247
+
248
+ def test_no_flag_shows_usage(self):
249
+ runner = CliRunner()
250
+ result = runner.invoke(cli, ["companion"])
251
+ assert result.exit_code == 0
252
+ assert "--interactive" in result.output
235253
236254
ADDED tests/test_companion.py
237255
ADDED tests/test_exchange.py
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -230,5 +230,23 @@
230 runner = CliRunner()
231 result = runner.invoke(cli, ["auth", "--help"])
232 assert result.exit_code == 0
233 assert "google" in result.output
234 assert "dropbox" in result.output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
236 DDED tests/test_companion.py
237 DDED tests/test_exchange.py
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -230,5 +230,23 @@
230 runner = CliRunner()
231 result = runner.invoke(cli, ["auth", "--help"])
232 assert result.exit_code == 0
233 assert "google" in result.output
234 assert "dropbox" in result.output
235
236
237 class TestCompanionHelp:
238 def test_help(self):
239 runner = CliRunner()
240 result = runner.invoke(cli, ["companion", "--help"])
241 assert result.exit_code == 0
242 assert "--kb" in result.output
243 assert "--provider" in result.output
244 assert "--chat-model" in result.output
245 assert "--interactive" in result.output
246 assert "--chat" in result.output
247
248 def test_no_flag_shows_usage(self):
249 runner = CliRunner()
250 result = runner.invoke(cli, ["companion"])
251 assert result.exit_code == 0
252 assert "--interactive" in result.output
253
254 DDED tests/test_companion.py
255 DDED tests/test_exchange.py
--- a/tests/test_companion.py
+++ b/tests/test_companion.py
@@ -0,0 +1,58 @@
1
+"""Tests for the CompanionREPL (without launching the loop)."""
2
+
3
+from unittest.mock import patch
4
+
5
+from video_processor.cli.companion import CompanionREPL
6
+
7
+
8
+class TestImport:
9
+ def test_import(self):
10
+ from video_processor.cli import companion # noqa: F401
11
+
12
+ assert hasattr(companion, "CompanionREPL")
13
+
14
+
15
+class TestConstructor:
16
+ def test_defaults(self):
17
+ repl = CompanionREPL()
18
+ assert repl.kg is None
19
+ assert repl.query_engine is None
20
+ assert repl.agent is None
21
+ assert repl.provider_manager is None
22
+
23
+ def test_explicit_args(self):
24
+ repl = CompanionREPL(
25
+ kb_paths=["/tmp/fake.db"],
26
+ provider="openai",
27
+ chat_model="gpt-4",
28
+ )
29
+ assert repl._kb_paths == ["/tmp/fake.db"]
30
+ assert repl._provider_name == "openai"
31
+ assert repl._chat_model == "gpt-4"
32
+
33
+
34
+class TestAutoDiscovery:
35
+ @patch(
36
+ "video_processor.integrators.graph_discovery.find_nearest_graph",
37
+ return_value=None,
38
+ )
39
+ def test_no_graph_found(self, mock_find):
40
+ repl = CompanionREPL()
41
+ repl._discover()
42
+ assert repl.query_engine is None
43
+ assert repl.kg is None
44
+ mock_find.assert_called_once()
45
+
46
+
47
+class TestHandleHelp:
48
+ def test_handle_help(self):
49
+ repl = CompanionREPL()
50
+ output = repl.handle_input("/help")
51
+ assert "Available commands" in output
52
+ assert "/status" in output
53
+ assert "/skills" in output
54
+ assert "/entities" in output
55
+ assert "/quit" in output
56
+
57
+
58
+class TestHandleStatus:
--- a/tests/test_companion.py
+++ b/tests/test_companion.py
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_companion.py
+++ b/tests/test_companion.py
@@ -0,0 +1,58 @@
1 """Tests for the CompanionREPL (without launching the loop)."""
2
3 from unittest.mock import patch
4
5 from video_processor.cli.companion import CompanionREPL
6
7
8 class TestImport:
9 def test_import(self):
10 from video_processor.cli import companion # noqa: F401
11
12 assert hasattr(companion, "CompanionREPL")
13
14
15 class TestConstructor:
16 def test_defaults(self):
17 repl = CompanionREPL()
18 assert repl.kg is None
19 assert repl.query_engine is None
20 assert repl.agent is None
21 assert repl.provider_manager is None
22
23 def test_explicit_args(self):
24 repl = CompanionREPL(
25 kb_paths=["/tmp/fake.db"],
26 provider="openai",
27 chat_model="gpt-4",
28 )
29 assert repl._kb_paths == ["/tmp/fake.db"]
30 assert repl._provider_name == "openai"
31 assert repl._chat_model == "gpt-4"
32
33
34 class TestAutoDiscovery:
35 @patch(
36 "video_processor.integrators.graph_discovery.find_nearest_graph",
37 return_value=None,
38 )
39 def test_no_graph_found(self, mock_find):
40 repl = CompanionREPL()
41 repl._discover()
42 assert repl.query_engine is None
43 assert repl.kg is None
44 mock_find.assert_called_once()
45
46
47 class TestHandleHelp:
48 def test_handle_help(self):
49 repl = CompanionREPL()
50 output = repl.handle_input("/help")
51 assert "Available commands" in output
52 assert "/status" in output
53 assert "/skills" in output
54 assert "/entities" in output
55 assert "/quit" in output
56
57
58 class TestHandleStatus:
--- a/tests/test_exchange.py
+++ b/tests/test_exchange.py
@@ -0,0 +1,224 @@
1
+"""Tests for the PlanOpticonExchange interchange format."""
2
+
3
+import json
4
+
5
+from video_processor.exchange import (
6
+ ArtifactMeta,
7
+ PlanOpticonExchange,
8
+ ProjectMeta,
9
+)
10
+from video_processor.models import Entity, Relationship, SourceRecord
11
+
12
+# ------------------------------------------------------------------
13
+# Fixtures
14
+# ------------------------------------------------------------------
15
+
16
+
17
+def _sample_entity(name: str = "Python", etype: str = "technology"):
18
+ return Entity(
19
+ name=name,
20
+ type=etype,
21
+ descriptions=["A programming language"],
22
+ )
23
+
24
+
25
+def _sample_relationship():
26
+ return Relationship(
27
+ source="Alice",
28
+ target="Python",
29
+ type="uses",
30
+ )
31
+
32
+
33
+def _sample_source():
34
+ return SourceRecord(
35
+ source_id="src-1",
36
+ source_type="video",
37
+ title="Intro recording",
38
+ )
39
+
40
+
41
+def _sample_artifact():
42
+ return ArtifactMeta(
43
+ name="roadmap",
44
+ content="# Roadmap\n- Phase 1",
45
+ artifact_type="roadmap",
46
+ format="markdown",
47
+ )
48
+
49
+
50
+def _sample_project():
51
+ return ProjectMeta(name="TestProject", description="A test")
52
+
53
+
54
+# ------------------------------------------------------------------
55
+# Tests
56
+# ------------------------------------------------------------------
57
+
58
+
59
+def test_create_empty_exchange():
60
+ ex = PlanOpticonExchange(project=_sample_project())
61
+ assert ex.version == "1.0"
62
+ assert ex.entities == []
63
+ assert ex.relationships == []
64
+ assert ex.artifacts == []
65
+ assert ex.sources == []
66
+ assert ex.project.name == "TestProject"
67
+
68
+
69
+def test_create_with_data():
70
+ ex = PlanOpticonExchange(
71
+ project=_sample_project(),
72
+ entities=[_sample_entity()],
73
+ relationships=[_sample_relationship()],
74
+ artifacts=[_sample_artifact()],
75
+ sources=[_sample_source()],
76
+ )
77
+ assert len(ex.entities) == 1
78
+ assert ex.entities[0].name == "Python"
79
+ assert len(ex.relationships) == 1
80
+ assert len(ex.artifacts) == 1
81
+ assert len(ex.sources) == 1
82
+
83
+
84
+def test_json_roundtrip(tmp_path):
85
+ original = PlanOpticonExchange(
86
+ project=_sample_project(),
87
+ entities=[_sample_entity()],
88
+ relationships=[_sample_relationship()],
89
+ artifacts=[_sample_artifact()],
90
+ sources=[_sample_source()],
91
+ )
92
+ out = tmp_path / "exchange.json"
93
+ original.to_file(out)
94
+
95
+ assert out.exists()
96
+ loaded = PlanOpticonExchange.from_file(out)
97
+ assert loaded.project.name == original.project.name
98
+ assert len(loaded.entities) == 1
99
+ assert loaded.entities[0].name == "Python"
100
+ assert len(loaded.relationships) == 1
101
+ assert len(loaded.artifacts) == 1
102
+ assert len(loaded.sources) == 1
103
+
104
+ # Verify valid JSON on disk
105
+ raw = json.loads(out.read_text())
106
+ assert raw["version"] == "1.0"
107
+
108
+
109
+def test_json_schema_export():
110
+ schema = PlanOpticonExchange.json_schema()
111
+ assert isinstance(schema, dict)
112
+ assert "properties" in schema
113
+ assert "version" in schema["properties"]
114
+ assert "project" in schema["properties"]
115
+ assert "entities" in schema["properties"]
116
+
117
+
118
+def test_from_knowledge_graph():
119
+ kg_dict = {
120
+ "nodes": [
121
+ {
122
+ "id": "python",
123
+ "name": "Python",
124
+ "type": "technology",
125
+ "descriptions": ["A language"],
126
+ "occurrences": [],
127
+ },
128
+ {
129
+ "id": "alice",
130
+ "name": "Alice",
131
+ "type": "person",
132
+ "descriptions": ["Engineer"],
133
+ "occurrences": [],
134
+ },
135
+ ],
136
+ "relationships": [
137
+ {
138
+ "source": "Alice",
139
+ "target": "Python",
140
+ "type": "uses",
141
+ },
142
+ ],
143
+ "sources": [
144
+ {
145
+ "source_id": "s1",
146
+ "source_type": "video",
147
+ "title": "Recording",
148
+ },
149
+ ],
150
+ }
151
+
152
+ ex = PlanOpticonExchange.from_knowledge_graph(
153
+ kg_dict,
154
+ project_name="Demo",
155
+ tags=["test"],
156
+ )
157
+ assert ex.project.name == "Demo"
158
+ assert len(ex.entities) == 2
159
+ assert len(ex.relationships) == 1
160
+ assert len(ex.sources) == 1
161
+ assert "test" in ex.project.tags
162
+
163
+
164
+def test_merge_deduplicates_entities():
165
+ ex1 = PlanOpticonExchange(
166
+ project=_sample_project(),
167
+ entities=[_sample_entity("Python"), _sample_entity("Rust")],
168
+ relationships=[_sample_relationship()],
169
+ sources=[_sample_source()],
170
+ )
171
+ ex2 = PlanOpticonExchange(
172
+ project=ProjectMeta(name="Other"),
173
+ entities=[
174
+ _sample_entity("Python"), # duplicate
175
+ _sample_entity("Go"), # new
176
+ ],
177
+ relationships=[
178
+ Relationship(source="Bob", target="Go", type="uses"),
179
+ ],
180
+ sources=[
181
+ SourceRecord(
182
+ source_id="src-2",
183
+ source_type="document",
184
+ title="Notes",
185
+ ),
186
+ ],
187
+ )
188
+
189
+ ex1.merge(ex2)
190
+
191
+ entity_names = [e.name for e in ex1.entities]
192
+ assert entity_names.count("Python") == 1
193
+ assert "Go" in entity_names
194
+ assert "Rust" in entity_names
195
+ assert len(ex1.entities) == 3
196
+ assert len(ex1.relationships) == 2
197
+ assert len(ex1.sources) == 2
198
+
199
+
200
+def test_version_field():
201
+ ex = PlanOpticonExchange(
202
+ version="2.0",
203
+ project=_sample_project(),
204
+ )
205
+ assert ex.version == "2.0"
206
+
207
+
208
+def test_artifact_meta_model():
209
+ art = ArtifactMeta(
210
+ name="plan",
211
+ content="# Plan\nDo stuff",
212
+ artifact_type="project_plan",
213
+ format="markdown",
214
+ metadata={"author": "agent"},
215
+ )
216
+ assert art.name == "plan"
217
+ assert art.artifact_type == "project_plan"
218
+ assert art.format == "markdown"
219
+ assert art.metadata == {"author": "agent"}
220
+
221
+ # Roundtrip via dict
222
+ d = art.model_dump()
223
+ restored = ArtifactMeta.model_validate(d)
224
+ assert restored == art
--- a/tests/test_exchange.py
+++ b/tests/test_exchange.py
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_exchange.py
+++ b/tests/test_exchange.py
@@ -0,0 +1,224 @@
1 """Tests for the PlanOpticonExchange interchange format."""
2
3 import json
4
5 from video_processor.exchange import (
6 ArtifactMeta,
7 PlanOpticonExchange,
8 ProjectMeta,
9 )
10 from video_processor.models import Entity, Relationship, SourceRecord
11
12 # ------------------------------------------------------------------
13 # Fixtures
14 # ------------------------------------------------------------------
15
16
17 def _sample_entity(name: str = "Python", etype: str = "technology"):
18 return Entity(
19 name=name,
20 type=etype,
21 descriptions=["A programming language"],
22 )
23
24
25 def _sample_relationship():
26 return Relationship(
27 source="Alice",
28 target="Python",
29 type="uses",
30 )
31
32
33 def _sample_source():
34 return SourceRecord(
35 source_id="src-1",
36 source_type="video",
37 title="Intro recording",
38 )
39
40
41 def _sample_artifact():
42 return ArtifactMeta(
43 name="roadmap",
44 content="# Roadmap\n- Phase 1",
45 artifact_type="roadmap",
46 format="markdown",
47 )
48
49
50 def _sample_project():
51 return ProjectMeta(name="TestProject", description="A test")
52
53
54 # ------------------------------------------------------------------
55 # Tests
56 # ------------------------------------------------------------------
57
58
59 def test_create_empty_exchange():
60 ex = PlanOpticonExchange(project=_sample_project())
61 assert ex.version == "1.0"
62 assert ex.entities == []
63 assert ex.relationships == []
64 assert ex.artifacts == []
65 assert ex.sources == []
66 assert ex.project.name == "TestProject"
67
68
69 def test_create_with_data():
70 ex = PlanOpticonExchange(
71 project=_sample_project(),
72 entities=[_sample_entity()],
73 relationships=[_sample_relationship()],
74 artifacts=[_sample_artifact()],
75 sources=[_sample_source()],
76 )
77 assert len(ex.entities) == 1
78 assert ex.entities[0].name == "Python"
79 assert len(ex.relationships) == 1
80 assert len(ex.artifacts) == 1
81 assert len(ex.sources) == 1
82
83
84 def test_json_roundtrip(tmp_path):
85 original = PlanOpticonExchange(
86 project=_sample_project(),
87 entities=[_sample_entity()],
88 relationships=[_sample_relationship()],
89 artifacts=[_sample_artifact()],
90 sources=[_sample_source()],
91 )
92 out = tmp_path / "exchange.json"
93 original.to_file(out)
94
95 assert out.exists()
96 loaded = PlanOpticonExchange.from_file(out)
97 assert loaded.project.name == original.project.name
98 assert len(loaded.entities) == 1
99 assert loaded.entities[0].name == "Python"
100 assert len(loaded.relationships) == 1
101 assert len(loaded.artifacts) == 1
102 assert len(loaded.sources) == 1
103
104 # Verify valid JSON on disk
105 raw = json.loads(out.read_text())
106 assert raw["version"] == "1.0"
107
108
109 def test_json_schema_export():
110 schema = PlanOpticonExchange.json_schema()
111 assert isinstance(schema, dict)
112 assert "properties" in schema
113 assert "version" in schema["properties"]
114 assert "project" in schema["properties"]
115 assert "entities" in schema["properties"]
116
117
118 def test_from_knowledge_graph():
119 kg_dict = {
120 "nodes": [
121 {
122 "id": "python",
123 "name": "Python",
124 "type": "technology",
125 "descriptions": ["A language"],
126 "occurrences": [],
127 },
128 {
129 "id": "alice",
130 "name": "Alice",
131 "type": "person",
132 "descriptions": ["Engineer"],
133 "occurrences": [],
134 },
135 ],
136 "relationships": [
137 {
138 "source": "Alice",
139 "target": "Python",
140 "type": "uses",
141 },
142 ],
143 "sources": [
144 {
145 "source_id": "s1",
146 "source_type": "video",
147 "title": "Recording",
148 },
149 ],
150 }
151
152 ex = PlanOpticonExchange.from_knowledge_graph(
153 kg_dict,
154 project_name="Demo",
155 tags=["test"],
156 )
157 assert ex.project.name == "Demo"
158 assert len(ex.entities) == 2
159 assert len(ex.relationships) == 1
160 assert len(ex.sources) == 1
161 assert "test" in ex.project.tags
162
163
164 def test_merge_deduplicates_entities():
165 ex1 = PlanOpticonExchange(
166 project=_sample_project(),
167 entities=[_sample_entity("Python"), _sample_entity("Rust")],
168 relationships=[_sample_relationship()],
169 sources=[_sample_source()],
170 )
171 ex2 = PlanOpticonExchange(
172 project=ProjectMeta(name="Other"),
173 entities=[
174 _sample_entity("Python"), # duplicate
175 _sample_entity("Go"), # new
176 ],
177 relationships=[
178 Relationship(source="Bob", target="Go", type="uses"),
179 ],
180 sources=[
181 SourceRecord(
182 source_id="src-2",
183 source_type="document",
184 title="Notes",
185 ),
186 ],
187 )
188
189 ex1.merge(ex2)
190
191 entity_names = [e.name for e in ex1.entities]
192 assert entity_names.count("Python") == 1
193 assert "Go" in entity_names
194 assert "Rust" in entity_names
195 assert len(ex1.entities) == 3
196 assert len(ex1.relationships) == 2
197 assert len(ex1.sources) == 2
198
199
200 def test_version_field():
201 ex = PlanOpticonExchange(
202 version="2.0",
203 project=_sample_project(),
204 )
205 assert ex.version == "2.0"
206
207
208 def test_artifact_meta_model():
209 art = ArtifactMeta(
210 name="plan",
211 content="# Plan\nDo stuff",
212 artifact_type="project_plan",
213 format="markdown",
214 metadata={"author": "agent"},
215 )
216 assert art.name == "plan"
217 assert art.artifact_type == "project_plan"
218 assert art.format == "markdown"
219 assert art.metadata == {"author": "agent"}
220
221 # Roundtrip via dict
222 d = art.model_dump()
223 restored = ArtifactMeta.model_validate(d)
224 assert restored == art
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1521,10 +1521,65 @@
15211521
kg_data = kg.to_dict()
15221522
created = export_to_notion_md(kg_data, out_dir)
15231523
15241524
click.echo(f"Exported Notion markdown: {len(created)} files in {out_dir}/")
15251525
1526
+
1527
+@export.command("exchange")
1528
+@click.argument("db_path", type=click.Path(exists=True))
1529
+@click.option(
1530
+ "-o",
1531
+ "--output",
1532
+ type=click.Path(),
1533
+ default=None,
1534
+ help="Output JSON file path",
1535
+)
1536
+@click.option(
1537
+ "--name",
1538
+ "project_name",
1539
+ type=str,
1540
+ default="Untitled",
1541
+ help="Project name for the exchange payload",
1542
+)
1543
+@click.option(
1544
+ "--description",
1545
+ "project_desc",
1546
+ type=str,
1547
+ default="",
1548
+ help="Project description",
1549
+)
1550
+def export_exchange(db_path, output, project_name, project_desc):
1551
+ """Export a knowledge graph as a PlanOpticonExchange JSON file.
1552
+
1553
+ Examples:
1554
+
1555
+ planopticon export exchange knowledge_graph.db
1556
+
1557
+ planopticon export exchange kg.db -o exchange.json --name "My Project"
1558
+ """
1559
+ from video_processor.exchange import PlanOpticonExchange
1560
+ from video_processor.integrators.knowledge_graph import KnowledgeGraph
1561
+
1562
+ db_path = Path(db_path)
1563
+ kg = KnowledgeGraph(db_path=db_path)
1564
+ kg_data = kg.to_dict()
1565
+
1566
+ ex = PlanOpticonExchange.from_knowledge_graph(
1567
+ kg_data,
1568
+ project_name=project_name,
1569
+ project_description=project_desc,
1570
+ )
1571
+
1572
+ out_path = Path(output) if output else Path.cwd() / "exchange.json"
1573
+ ex.to_file(out_path)
1574
+
1575
+ click.echo(
1576
+ f"Exported PlanOpticonExchange to {out_path} "
1577
+ f"({len(ex.entities)} entities, "
1578
+ f"{len(ex.relationships)} relationships)"
1579
+ )
1580
+
15261581
15271582
@cli.group()
15281583
def wiki():
15291584
"""Generate and push GitHub wikis from knowledge graphs."""
15301585
pass
@@ -1893,10 +1948,115 @@
18931948
if pe.description:
18941949
click.echo(f" {pe.description}")
18951950
18961951
store.close()
18971952
1953
+
1954
+@kg.command("from-exchange")
1955
+@click.argument("exchange_path", type=click.Path(exists=True))
1956
+@click.option(
1957
+ "-o",
1958
+ "--output",
1959
+ "db_path",
1960
+ type=click.Path(),
1961
+ default=None,
1962
+ help="Output .db file path",
1963
+)
1964
+def kg_from_exchange(exchange_path, db_path):
1965
+ """Import a PlanOpticonExchange JSON file into a knowledge graph .db.
1966
+
1967
+ Examples:
1968
+
1969
+ planopticon kg from-exchange exchange.json
1970
+
1971
+ planopticon kg from-exchange exchange.json -o project.db
1972
+ """
1973
+ from video_processor.exchange import PlanOpticonExchange
1974
+ from video_processor.integrators.knowledge_graph import KnowledgeGraph
1975
+
1976
+ ex = PlanOpticonExchange.from_file(exchange_path)
1977
+
1978
+ kg_dict = {
1979
+ "nodes": [e.model_dump() for e in ex.entities],
1980
+ "relationships": [r.model_dump() for r in ex.relationships],
1981
+ "sources": [s.model_dump() for s in ex.sources],
1982
+ }
1983
+
1984
+ out = Path(db_path) if db_path else Path.cwd() / "knowledge_graph.db"
1985
+ kg_obj = KnowledgeGraph.from_dict(kg_dict, db_path=out)
1986
+ kg_obj.save(out)
1987
+
1988
+ click.echo(
1989
+ f"Imported exchange into {out} "
1990
+ f"({len(ex.entities)} entities, "
1991
+ f"{len(ex.relationships)} relationships)"
1992
+ )
1993
+
1994
+
1995
+@cli.command()
1996
+@click.option(
1997
+ "--interactive",
1998
+ "-I",
1999
+ "interactive_mode",
2000
+ is_flag=True,
2001
+ help="Launch interactive REPL",
2002
+)
2003
+@click.option(
2004
+ "--chat",
2005
+ "-C",
2006
+ "chat_mode",
2007
+ is_flag=True,
2008
+ help="Launch interactive REPL (alias for --interactive)",
2009
+)
2010
+@click.option(
2011
+ "--kb",
2012
+ multiple=True,
2013
+ type=click.Path(exists=True),
2014
+ help="Knowledge base paths",
2015
+)
2016
+@click.option(
2017
+ "--provider",
2018
+ "-p",
2019
+ type=str,
2020
+ default="auto",
2021
+ help="LLM provider (auto, openai, anthropic, ...)",
2022
+)
2023
+@click.option(
2024
+ "--chat-model",
2025
+ type=str,
2026
+ default=None,
2027
+ help="Chat model override",
2028
+)
2029
+@click.pass_context
2030
+def companion(ctx, interactive_mode, chat_mode, kb, provider, chat_model):
2031
+ """Planning companion with workspace awareness.
2032
+
2033
+ Use --interactive or --chat to start the REPL.
2034
+
2035
+ Examples:
2036
+
2037
+ planopticon companion --interactive
2038
+
2039
+ planopticon companion --chat --kb ./results
2040
+
2041
+ planopticon companion -I -p anthropic
2042
+ """
2043
+ if not interactive_mode and not chat_mode:
2044
+ click.echo("Use --interactive or --chat to start the companion REPL.")
2045
+ click.echo("Example: planopticon companion --interactive")
2046
+ click.echo("\nRun 'planopticon companion --help' for options.")
2047
+ return
2048
+
2049
+ from video_processor.cli.companion import CompanionREPL
2050
+
2051
+ repl = CompanionREPL(
2052
+ kb_paths=list(kb),
2053
+ provider=provider,
2054
+ chat_model=chat_model,
2055
+ )
2056
+ repl.run()
2057
+
18982058
18992059
def _interactive_menu(ctx):
19002060
"""Show an interactive menu when planopticon is run with no arguments."""
19012061
click.echo()
19022062
click.echo(" PlanOpticon v0.2.0")
19032063
19042064
ADDED video_processor/cli/companion.py
19052065
ADDED video_processor/exchange.py
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1521,10 +1521,65 @@
1521 kg_data = kg.to_dict()
1522 created = export_to_notion_md(kg_data, out_dir)
1523
1524 click.echo(f"Exported Notion markdown: {len(created)} files in {out_dir}/")
1525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1526
1527 @cli.group()
1528 def wiki():
1529 """Generate and push GitHub wikis from knowledge graphs."""
1530 pass
@@ -1893,10 +1948,115 @@
1893 if pe.description:
1894 click.echo(f" {pe.description}")
1895
1896 store.close()
1897
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1898
1899 def _interactive_menu(ctx):
1900 """Show an interactive menu when planopticon is run with no arguments."""
1901 click.echo()
1902 click.echo(" PlanOpticon v0.2.0")
1903
1904 DDED video_processor/cli/companion.py
1905 DDED video_processor/exchange.py
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1521,10 +1521,65 @@
1521 kg_data = kg.to_dict()
1522 created = export_to_notion_md(kg_data, out_dir)
1523
1524 click.echo(f"Exported Notion markdown: {len(created)} files in {out_dir}/")
1525
1526
1527 @export.command("exchange")
1528 @click.argument("db_path", type=click.Path(exists=True))
1529 @click.option(
1530 "-o",
1531 "--output",
1532 type=click.Path(),
1533 default=None,
1534 help="Output JSON file path",
1535 )
1536 @click.option(
1537 "--name",
1538 "project_name",
1539 type=str,
1540 default="Untitled",
1541 help="Project name for the exchange payload",
1542 )
1543 @click.option(
1544 "--description",
1545 "project_desc",
1546 type=str,
1547 default="",
1548 help="Project description",
1549 )
1550 def export_exchange(db_path, output, project_name, project_desc):
1551 """Export a knowledge graph as a PlanOpticonExchange JSON file.
1552
1553 Examples:
1554
1555 planopticon export exchange knowledge_graph.db
1556
1557 planopticon export exchange kg.db -o exchange.json --name "My Project"
1558 """
1559 from video_processor.exchange import PlanOpticonExchange
1560 from video_processor.integrators.knowledge_graph import KnowledgeGraph
1561
1562 db_path = Path(db_path)
1563 kg = KnowledgeGraph(db_path=db_path)
1564 kg_data = kg.to_dict()
1565
1566 ex = PlanOpticonExchange.from_knowledge_graph(
1567 kg_data,
1568 project_name=project_name,
1569 project_description=project_desc,
1570 )
1571
1572 out_path = Path(output) if output else Path.cwd() / "exchange.json"
1573 ex.to_file(out_path)
1574
1575 click.echo(
1576 f"Exported PlanOpticonExchange to {out_path} "
1577 f"({len(ex.entities)} entities, "
1578 f"{len(ex.relationships)} relationships)"
1579 )
1580
1581
1582 @cli.group()
1583 def wiki():
1584 """Generate and push GitHub wikis from knowledge graphs."""
1585 pass
@@ -1893,10 +1948,115 @@
1948 if pe.description:
1949 click.echo(f" {pe.description}")
1950
1951 store.close()
1952
1953
1954 @kg.command("from-exchange")
1955 @click.argument("exchange_path", type=click.Path(exists=True))
1956 @click.option(
1957 "-o",
1958 "--output",
1959 "db_path",
1960 type=click.Path(),
1961 default=None,
1962 help="Output .db file path",
1963 )
1964 def kg_from_exchange(exchange_path, db_path):
1965 """Import a PlanOpticonExchange JSON file into a knowledge graph .db.
1966
1967 Examples:
1968
1969 planopticon kg from-exchange exchange.json
1970
1971 planopticon kg from-exchange exchange.json -o project.db
1972 """
1973 from video_processor.exchange import PlanOpticonExchange
1974 from video_processor.integrators.knowledge_graph import KnowledgeGraph
1975
1976 ex = PlanOpticonExchange.from_file(exchange_path)
1977
1978 kg_dict = {
1979 "nodes": [e.model_dump() for e in ex.entities],
1980 "relationships": [r.model_dump() for r in ex.relationships],
1981 "sources": [s.model_dump() for s in ex.sources],
1982 }
1983
1984 out = Path(db_path) if db_path else Path.cwd() / "knowledge_graph.db"
1985 kg_obj = KnowledgeGraph.from_dict(kg_dict, db_path=out)
1986 kg_obj.save(out)
1987
1988 click.echo(
1989 f"Imported exchange into {out} "
1990 f"({len(ex.entities)} entities, "
1991 f"{len(ex.relationships)} relationships)"
1992 )
1993
1994
1995 @cli.command()
1996 @click.option(
1997 "--interactive",
1998 "-I",
1999 "interactive_mode",
2000 is_flag=True,
2001 help="Launch interactive REPL",
2002 )
2003 @click.option(
2004 "--chat",
2005 "-C",
2006 "chat_mode",
2007 is_flag=True,
2008 help="Launch interactive REPL (alias for --interactive)",
2009 )
2010 @click.option(
2011 "--kb",
2012 multiple=True,
2013 type=click.Path(exists=True),
2014 help="Knowledge base paths",
2015 )
2016 @click.option(
2017 "--provider",
2018 "-p",
2019 type=str,
2020 default="auto",
2021 help="LLM provider (auto, openai, anthropic, ...)",
2022 )
2023 @click.option(
2024 "--chat-model",
2025 type=str,
2026 default=None,
2027 help="Chat model override",
2028 )
2029 @click.pass_context
2030 def companion(ctx, interactive_mode, chat_mode, kb, provider, chat_model):
2031 """Planning companion with workspace awareness.
2032
2033 Use --interactive or --chat to start the REPL.
2034
2035 Examples:
2036
2037 planopticon companion --interactive
2038
2039 planopticon companion --chat --kb ./results
2040
2041 planopticon companion -I -p anthropic
2042 """
2043 if not interactive_mode and not chat_mode:
2044 click.echo("Use --interactive or --chat to start the companion REPL.")
2045 click.echo("Example: planopticon companion --interactive")
2046 click.echo("\nRun 'planopticon companion --help' for options.")
2047 return
2048
2049 from video_processor.cli.companion import CompanionREPL
2050
2051 repl = CompanionREPL(
2052 kb_paths=list(kb),
2053 provider=provider,
2054 chat_model=chat_model,
2055 )
2056 repl.run()
2057
2058
2059 def _interactive_menu(ctx):
2060 """Show an interactive menu when planopticon is run with no arguments."""
2061 click.echo()
2062 click.echo(" PlanOpticon v0.2.0")
2063
2064 DDED video_processor/cli/companion.py
2065 DDED video_processor/exchange.py
--- a/video_processor/cli/companion.py
+++ b/video_processor/cli/companion.py
@@ -0,0 +1,378 @@
1
+"""Interactive planning companion REPL for PlanOpticon."""
2
+
3
+import logging
4
+from pathlib import Path
5
+from typing import List, Optional
6
+
7
+logger = logging.getLogger(__name__)
8
+
9
+VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
10
+DOC_EXTS = {".md", ".pdf", ".docx"}
11
+
12
+
13
+class CompanionREPL:
14
+ """Smart REPL with workspace awareness and KG querying."""
15
+
16
+ def __init__(
17
+ self,
18
+ kb_paths: Optional[List[str]] = None,
19
+ provider: str = "auto",
20
+ chat_model: Optional[str] = None,
21
+ ):
22
+ self.kg = None
23
+ self.query_engine = None
24
+ self.agent = None
25
+ self.provider_manager = None
26
+ self._kb_paths = kb_paths or []
27
+ self._provider_name = provider
28
+ self._chat_model = chat_model
29
+ self._videos: List[Path] = []
30
+ self._docs: List[Path] = []
31
+ self._kg_path: Optional[Path] = None
32
+
33
+ def _discover(self) -> None:
34
+ """Auto-discover workspace context."""
35
+ # Discover knowledge graphs
36
+ from video_processor.integrators.graph_discovery import (
37
+ find_nearest_graph,
38
+ )
39
+
40
+ if self._kb_paths:
41
+ # Use explicit paths
42
+ self._kg_path = Path(self._kb_paths[0])
43
+ else:
44
+ self._kg_path = find_nearest_graph()
45
+
46
+ if self._kg_path and self._kg_path.exists():
47
+ self._load_kg(self._kg_path)
48
+
49
+ # Scan for media and doc files in cwd
50
+ cwd = Path.cwd()
51
+ try:
52
+ for f in sorted(cwd.iterdir()):
53
+ if f.suffix.lower() in VIDEO_EXTS:
54
+ self._videos.append(f)
55
+ elif f.suffix.lower() in DOC_EXTS:
56
+ self._docs.append(f)
57
+ except PermissionError:
58
+ pass
59
+
60
+ def _load_kg(self, path: Path) -> None:
61
+ """Load a knowledge graph from a file path."""
62
+ from video_processor.integrators.graph_query import (
63
+ GraphQueryEngine,
64
+ )
65
+
66
+ try:
67
+ if path.suffix == ".json":
68
+ self.query_engine = GraphQueryEngine.from_json_path(path)
69
+ else:
70
+ self.query_engine = GraphQueryEngine.from_db_path(path)
71
+ self.kg = self.query_engine.store
72
+ except Exception as exc:
73
+ logger.debug("Failed to load KG at %s: %s", path, exc)
74
+
75
+ def _init_provider(self) -> None:
76
+ """Try to initialise an LLM provider."""
77
+ try:
78
+ from video_processor.providers.manager import (
79
+ ProviderManager,
80
+ )
81
+
82
+ prov = None if self._provider_name == "auto" else self._provider_name
83
+ self.provider_manager = ProviderManager(
84
+ chat_model=self._chat_model,
85
+ provider=prov,
86
+ )
87
+ except Exception:
88
+ self.provider_manager = None
89
+
90
+ def _init_agent(self) -> None:
91
+ """Create a PlanningAgent if possible."""
92
+ try:
93
+ from video_processor.agent.agent_loop import (
94
+ PlanningAgent,
95
+ )
96
+ from video_processor.agent.skills.base import (
97
+ AgentContext,
98
+ )
99
+
100
+ ctx = AgentContext(
101
+ knowledge_graph=self.kg,
102
+ query_engine=self.query_engine,
103
+ provider_manager=self.provider_manager,
104
+ )
105
+ self.agent = PlanningAgent(context=ctx)
106
+ except Exception:
107
+ self.agent = None
108
+
109
+ def _welcome_banner(self) -> str:
110
+ """Build the welcome banner text."""
111
+ lines = [
112
+ "",
113
+ " PlanOpticon Companion",
114
+ " Interactive planning REPL",
115
+ "",
116
+ ]
117
+
118
+ if self._kg_path and self.query_engine:
119
+ stats = self.query_engine.stats().data
120
+ lines.append(
121
+ f" Knowledge graph: {self._kg_path.name}"
122
+ f" ({stats['entity_count']} entities,"
123
+ f" {stats['relationship_count']} relationships)"
124
+ )
125
+ else:
126
+ lines.append(" No knowledge graph loaded.")
127
+
128
+ if self._videos:
129
+ names = ", ".join(v.name for v in self._videos[:3])
130
+ suffix = f" (+{len(self._videos) - 3} more)" if len(self._videos) > 3 else ""
131
+ lines.append(f" Videos: {names}{suffix}")
132
+
133
+ if self._docs:
134
+ names = ", ".join(d.name for d in self._docs[:3])
135
+ suffix = f" (+{len(self._docs) - 3} more)" if len(self._docs) > 3 else ""
136
+ lines.append(f" Docs: provider_label = "active" if
137
+
138
+ def _discover(selelse "none"
139
+ext."
140
+ try:
141
+ lines.appider_label}ne")
142
+ lines.appendne")
143
+ lines.append("")
144
+ lines.append(" Type /help for commands, or ask a question.")
145
+ lines.append("")
146
+ return "\n".join(lines)
147
+
148
+ # ── Command handlers ──
149
+
150
+ def _cmd_help(self) -> str:
151
+ lines = [
152
+ "Available commands:",
153
+ " /help Show this help",
154
+ " /status Workspace status",
155
+ " /skills List available skills",
156
+ " /entities [--type T] List KG entities",
157
+ " /search TERM Search entities by name",
158
+ " /neighbors ENTITY Show entity relationships",
159
+ " /export FORMAT Export KG (markdown, obsidian, notion, csv)",
160
+ " /analyze PATH Analyze a video/doc",
161
+ " /ingest PATH Ingest a file intrun SKILL Run a skill by name",
162
+ " /plan Run project_plan skill",
163
+ " /prd Run PRD skill",
164
+ " /tasks Run task_breakdown skill",
165
+ " /quit, /exit Exit companion",
166
+ "",
167
+ "Any other input is sent to the chat agent (requires LLM).",
168
+ ]
169
+ return "\n".join(lines)
170
+
171
+ def _cmd_status(self) -> str:
172
+ lines = ["Workspace status:"]
173
+ if self._kg_path and self.query_engine:
174
+ stats = self.query_engine.stats().data
175
+ lines.append(
176
+ f" KG: {self._kg_path}"
177
+ f" ({stats['entity_count']} entities,"
178
+ f" {stats['relationship_count']} relationships)"
179
+ )
180
+ if stats.get("entity_types"):
181
+ for t, c in sorted(
182
+ stats["entity_types"].items(),
183
+ key=lambda x: -x[1],
184
+ ):
185
+ lines.append(f" {t}: {c}")
186
+ else:
187
+ lines.append(" KG: not loaded")
188
+
189
+ lines.append(f" Videos: {len(self._videos)} found")
190
+ lines.append(f" Docs: {len(self._docs)} found")
191
+ lines.append(f" Provider: {'active' if self.provider_manager else 'none'}")
192
+ return "\n".join(lines)
193
+
194
+ def _cmd_skills(self) -> str:
195
+ from video_processor.agent.skills.base import (
196
+ list_skills,
197
+ )
198
+
199
+ skills = list_skills()
200
+ if not skills:
201
+ return "No skills registered."
202
+ lines = ["Available skills:"]
203
+ for s in skills:
204
+ lines.append(f" {s.name}: {s.description}")
205
+ return "\n".join(lines)
206
+
207
+ def _cmd_entities(self, args: str) -> str:
208
+ if not self.query_engine:
209
+ return "No knowledge graph loaded."
210
+ entity_type = None
211
+ parts = args.split()
212
+ for i, part in enumerate(parts):
213
+ if part == "--type" and i + 1 < len(parts):
214
+ entity_type = parts[i + 1]
215
+ result = self.query_engine.entities(
216
+ entity_type=entity_type,
217
+ )
218
+ return result.to_text()
219
+
220
+ def _cmd_search(self, term: str) -> str:
221
+ if not self.query_engine:
222
+ return "No knowledge graph loaded."
223
+ term = term.strip()
224
+ if not term:
225
+ return "Usage: /search TERM"
226
+ result = self.query_engine.entities(name=term)
227
+ return result.to_text()
228
+
229
+ def _cmd_neighbors(self, entity: str) -> str:
230
+ if not self.query_engine:
231
+ return "No knowledge graph loaded."
232
+ entity = entity.strip()
233
+ if not entity:
234
+ return "Usage: /neighbors ENTITY"
235
+ result = self.query_engine.neighbors(entity)
236
+ return result.to_text()
237
+
238
+ def _cmd_export(self, fmt: str) -> str:
239
+ fmt = fmt.strip().lower()
240
+ if not fmt:
241
+ return "Usage: /export FORMAT (markdown, obsidian, notion, csv)"
242
+ if not self._kg_path:
243
+ return "No knowledge graph loaded."
244
+ return (
245
+ f"Export '{fmt}' requested. Use the CLI command:\n"
246
+ f" planopticon export {fmt} {self._kg_path}"
247
+ )
248
+
249
+ def _cmd_analyze(self, path_str: str) -> str:
250
+ path_str = path_str.strip()
251
+ if not path_str:
252
+ return "Usage: /analyze PATH"
253
+ p = Path(path_str)
254
+ if not p.exists():
255
+ return f"File not found: {path_str}"
256
+ return f"Analyze requested for {p.name}. Use the CLI:\n planopticon analyze -i {p}"
257
+
258
+ def _cmd_ingest(self, path_str: str) -> str:
259
+ path_str = path_str.strip()
260
+ if not path_str:
261
+ return "Usage: /ingest PATH"
262
+ p = Path(path_str)
263
+ if not p.exists():
264
+ return f"File not found: {path_str}"
265
+ return f"Ingest requested for {p.name}. Use the CLI:\n planopticon ingest {p}"
266
+
267
+ def _cmd_run_skill(self, skill_name: str) -> str:
268
+ skill_name = skill_name.strip()
269
+ if not skill_name:
270
+ return "Usage: /run SKILL_NAME"
271
+ from video_processor.agent.skills.base import (
272
+ get_skill,
273
+ )
274
+
275
+ skill = get_skill(skill_name)
276
+ if not skill:
277
+ return f"Unknown skill: {skill_name}"
278
+ if not self.agent:
279
+ return "Agent not initialised (no LLM provider?)."
280
+ if not skill.can_execute(self.agent.context):
281
+ return f"Skill '{skill_name}' cannot execute in current context."
282
+ try:
283
+ artifact = skill.execute(self.agent.context)
284
+ return f"--- {artifact.name} ({artifact.artifact_type}) ---\n{artifact.content}"
285
+ except Exception as exc:
286
+ return f"Skill execution fa return (
287
+ "Chat requires an LLM provider. Set one of:\n"
288
+ " OPENAI_API_KEY\n"
289
+ " ANTHROPIC_API_KEY\n"
290
+ " GEMINI_API_KEY\n"
291
+ "Or pass --provider / --chat-model."
292
+ )
293
+ try:
294
+ return self.agent.chat(message)
295
+ except Exception as exc:
296
+ return f"Chat error: {exc}"
297
+
298
+ # ── Main dispatch ──
299
+
300
+ def handle_input(self, line: str) -> str:
301
+ """Process a single def run find_nearest_graph,
302
+ Main REPL loop."""
303
+ self._discover()
304
+ self._init_provider()
305
+
306
+ xcept (KeyboardInterrupt, EOFError):
307
+ print("\nBye.")
308
+ break
309
+
310
+ output = self.handle_input(line)
311
+ if output == "__QUIT__":
312
+ print("Bye.")
313
+ break
314
+ if output:
315
+ print(output)
316
+Interactive planning companion REPL for PlanOpticon."""
317
+
318
+import logging
319
+from pathlib import Path
320
+from typing import List, Optional
321
+
322
+logger = logging.getLogger(__name__)
323
+
324
+VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
325
+DOC_EXTS = {".md", ".pdf", ".docx"}
326
+
327
+
328
+class CompanionREPL:
329
+ """Smart REPL with workspace awareness and KG querying."""
330
+
331
+ def __init__(
332
+ self,
333
+ kb_paths: Optional[List[str]] = None,
334
+ provider: str = "auto",
335
+ chat_model: Optional[str] = None,
336
+ ):
337
+ self.kg = None
338
+ self.query_engine = None
339
+ self.agent = None
340
+ self.provider_manager = None
341
+ self._kb_paths = kb_paths or []
342
+ self._provider_name = provider
343
+ self._chat_model = chat_model
344
+ self._videos: List[Path] = []
345
+ self._docs: List[Path] = []
346
+ self._kg_path: Optional[Path] = None
347
+
348
+ def _discover(self) -> None:
349
+ """Auto-discover worklanning companion REPL for PlanOpticon."""
350
+
351
+import logging
352
+from pathlib import Path
353
+from typing import List, Optional
354
+
355
+logger = logging.getLogger(__name__)
356
+
357
+VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
358
+DOC_EXTS = {".md", ".pdf", ".docx"}
359
+
360
+
361
+class CompanionREPL:
362
+ """Smart REPL with workspace awareness and KG querying."""
363
+
364
+ def __init__(
365
+ self,
366
+ kb_paths: Optional[List[str]] = None,
367
+ provider: str = "auto",
368
+ chat_model: Optional[str] = None,
369
+ ):
370
+ self.kg = None
371
+ self.query_engine = None
372
+ self.agent = None
373
+ self.provider_manager = None
374
+ self._kb_paths = kb_paths or []
375
+ self._provider_name = provider
376
+ self._chat_model = chat_model
377
+ self._videos: List[Path] = []
378
+ self._docs: List[Path]
--- a/video_processor/cli/companion.py
+++ b/video_processor/cli/companion.py
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/cli/companion.py
+++ b/video_processor/cli/companion.py
@@ -0,0 +1,378 @@
1 """Interactive planning companion REPL for PlanOpticon."""
2
3 import logging
4 from pathlib import Path
5 from typing import List, Optional
6
7 logger = logging.getLogger(__name__)
8
9 VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
10 DOC_EXTS = {".md", ".pdf", ".docx"}
11
12
13 class CompanionREPL:
14 """Smart REPL with workspace awareness and KG querying."""
15
16 def __init__(
17 self,
18 kb_paths: Optional[List[str]] = None,
19 provider: str = "auto",
20 chat_model: Optional[str] = None,
21 ):
22 self.kg = None
23 self.query_engine = None
24 self.agent = None
25 self.provider_manager = None
26 self._kb_paths = kb_paths or []
27 self._provider_name = provider
28 self._chat_model = chat_model
29 self._videos: List[Path] = []
30 self._docs: List[Path] = []
31 self._kg_path: Optional[Path] = None
32
33 def _discover(self) -> None:
34 """Auto-discover workspace context."""
35 # Discover knowledge graphs
36 from video_processor.integrators.graph_discovery import (
37 find_nearest_graph,
38 )
39
40 if self._kb_paths:
41 # Use explicit paths
42 self._kg_path = Path(self._kb_paths[0])
43 else:
44 self._kg_path = find_nearest_graph()
45
46 if self._kg_path and self._kg_path.exists():
47 self._load_kg(self._kg_path)
48
49 # Scan for media and doc files in cwd
50 cwd = Path.cwd()
51 try:
52 for f in sorted(cwd.iterdir()):
53 if f.suffix.lower() in VIDEO_EXTS:
54 self._videos.append(f)
55 elif f.suffix.lower() in DOC_EXTS:
56 self._docs.append(f)
57 except PermissionError:
58 pass
59
60 def _load_kg(self, path: Path) -> None:
61 """Load a knowledge graph from a file path."""
62 from video_processor.integrators.graph_query import (
63 GraphQueryEngine,
64 )
65
66 try:
67 if path.suffix == ".json":
68 self.query_engine = GraphQueryEngine.from_json_path(path)
69 else:
70 self.query_engine = GraphQueryEngine.from_db_path(path)
71 self.kg = self.query_engine.store
72 except Exception as exc:
73 logger.debug("Failed to load KG at %s: %s", path, exc)
74
75 def _init_provider(self) -> None:
76 """Try to initialise an LLM provider."""
77 try:
78 from video_processor.providers.manager import (
79 ProviderManager,
80 )
81
82 prov = None if self._provider_name == "auto" else self._provider_name
83 self.provider_manager = ProviderManager(
84 chat_model=self._chat_model,
85 provider=prov,
86 )
87 except Exception:
88 self.provider_manager = None
89
90 def _init_agent(self) -> None:
91 """Create a PlanningAgent if possible."""
92 try:
93 from video_processor.agent.agent_loop import (
94 PlanningAgent,
95 )
96 from video_processor.agent.skills.base import (
97 AgentContext,
98 )
99
100 ctx = AgentContext(
101 knowledge_graph=self.kg,
102 query_engine=self.query_engine,
103 provider_manager=self.provider_manager,
104 )
105 self.agent = PlanningAgent(context=ctx)
106 except Exception:
107 self.agent = None
108
109 def _welcome_banner(self) -> str:
110 """Build the welcome banner text."""
111 lines = [
112 "",
113 " PlanOpticon Companion",
114 " Interactive planning REPL",
115 "",
116 ]
117
118 if self._kg_path and self.query_engine:
119 stats = self.query_engine.stats().data
120 lines.append(
121 f" Knowledge graph: {self._kg_path.name}"
122 f" ({stats['entity_count']} entities,"
123 f" {stats['relationship_count']} relationships)"
124 )
125 else:
126 lines.append(" No knowledge graph loaded.")
127
128 if self._videos:
129 names = ", ".join(v.name for v in self._videos[:3])
130 suffix = f" (+{len(self._videos) - 3} more)" if len(self._videos) > 3 else ""
131 lines.append(f" Videos: {names}{suffix}")
132
133 if self._docs:
134 names = ", ".join(d.name for d in self._docs[:3])
135 suffix = f" (+{len(self._docs) - 3} more)" if len(self._docs) > 3 else ""
136 lines.append(f" Docs: provider_label = "active" if
137
138 def _discover(selelse "none"
139 ext."
140 try:
141 lines.appider_label}ne")
142 lines.appendne")
143 lines.append("")
144 lines.append(" Type /help for commands, or ask a question.")
145 lines.append("")
146 return "\n".join(lines)
147
148 # ── Command handlers ──
149
150 def _cmd_help(self) -> str:
151 lines = [
152 "Available commands:",
153 " /help Show this help",
154 " /status Workspace status",
155 " /skills List available skills",
156 " /entities [--type T] List KG entities",
157 " /search TERM Search entities by name",
158 " /neighbors ENTITY Show entity relationships",
159 " /export FORMAT Export KG (markdown, obsidian, notion, csv)",
160 " /analyze PATH Analyze a video/doc",
161 " /ingest PATH Ingest a file intrun SKILL Run a skill by name",
162 " /plan Run project_plan skill",
163 " /prd Run PRD skill",
164 " /tasks Run task_breakdown skill",
165 " /quit, /exit Exit companion",
166 "",
167 "Any other input is sent to the chat agent (requires LLM).",
168 ]
169 return "\n".join(lines)
170
171 def _cmd_status(self) -> str:
172 lines = ["Workspace status:"]
173 if self._kg_path and self.query_engine:
174 stats = self.query_engine.stats().data
175 lines.append(
176 f" KG: {self._kg_path}"
177 f" ({stats['entity_count']} entities,"
178 f" {stats['relationship_count']} relationships)"
179 )
180 if stats.get("entity_types"):
181 for t, c in sorted(
182 stats["entity_types"].items(),
183 key=lambda x: -x[1],
184 ):
185 lines.append(f" {t}: {c}")
186 else:
187 lines.append(" KG: not loaded")
188
189 lines.append(f" Videos: {len(self._videos)} found")
190 lines.append(f" Docs: {len(self._docs)} found")
191 lines.append(f" Provider: {'active' if self.provider_manager else 'none'}")
192 return "\n".join(lines)
193
194 def _cmd_skills(self) -> str:
195 from video_processor.agent.skills.base import (
196 list_skills,
197 )
198
199 skills = list_skills()
200 if not skills:
201 return "No skills registered."
202 lines = ["Available skills:"]
203 for s in skills:
204 lines.append(f" {s.name}: {s.description}")
205 return "\n".join(lines)
206
207 def _cmd_entities(self, args: str) -> str:
208 if not self.query_engine:
209 return "No knowledge graph loaded."
210 entity_type = None
211 parts = args.split()
212 for i, part in enumerate(parts):
213 if part == "--type" and i + 1 < len(parts):
214 entity_type = parts[i + 1]
215 result = self.query_engine.entities(
216 entity_type=entity_type,
217 )
218 return result.to_text()
219
220 def _cmd_search(self, term: str) -> str:
221 if not self.query_engine:
222 return "No knowledge graph loaded."
223 term = term.strip()
224 if not term:
225 return "Usage: /search TERM"
226 result = self.query_engine.entities(name=term)
227 return result.to_text()
228
229 def _cmd_neighbors(self, entity: str) -> str:
230 if not self.query_engine:
231 return "No knowledge graph loaded."
232 entity = entity.strip()
233 if not entity:
234 return "Usage: /neighbors ENTITY"
235 result = self.query_engine.neighbors(entity)
236 return result.to_text()
237
238 def _cmd_export(self, fmt: str) -> str:
239 fmt = fmt.strip().lower()
240 if not fmt:
241 return "Usage: /export FORMAT (markdown, obsidian, notion, csv)"
242 if not self._kg_path:
243 return "No knowledge graph loaded."
244 return (
245 f"Export '{fmt}' requested. Use the CLI command:\n"
246 f" planopticon export {fmt} {self._kg_path}"
247 )
248
249 def _cmd_analyze(self, path_str: str) -> str:
250 path_str = path_str.strip()
251 if not path_str:
252 return "Usage: /analyze PATH"
253 p = Path(path_str)
254 if not p.exists():
255 return f"File not found: {path_str}"
256 return f"Analyze requested for {p.name}. Use the CLI:\n planopticon analyze -i {p}"
257
258 def _cmd_ingest(self, path_str: str) -> str:
259 path_str = path_str.strip()
260 if not path_str:
261 return "Usage: /ingest PATH"
262 p = Path(path_str)
263 if not p.exists():
264 return f"File not found: {path_str}"
265 return f"Ingest requested for {p.name}. Use the CLI:\n planopticon ingest {p}"
266
267 def _cmd_run_skill(self, skill_name: str) -> str:
268 skill_name = skill_name.strip()
269 if not skill_name:
270 return "Usage: /run SKILL_NAME"
271 from video_processor.agent.skills.base import (
272 get_skill,
273 )
274
275 skill = get_skill(skill_name)
276 if not skill:
277 return f"Unknown skill: {skill_name}"
278 if not self.agent:
279 return "Agent not initialised (no LLM provider?)."
280 if not skill.can_execute(self.agent.context):
281 return f"Skill '{skill_name}' cannot execute in current context."
282 try:
283 artifact = skill.execute(self.agent.context)
284 return f"--- {artifact.name} ({artifact.artifact_type}) ---\n{artifact.content}"
285 except Exception as exc:
286 return f"Skill execution fa return (
287 "Chat requires an LLM provider. Set one of:\n"
288 " OPENAI_API_KEY\n"
289 " ANTHROPIC_API_KEY\n"
290 " GEMINI_API_KEY\n"
291 "Or pass --provider / --chat-model."
292 )
293 try:
294 return self.agent.chat(message)
295 except Exception as exc:
296 return f"Chat error: {exc}"
297
298 # ── Main dispatch ──
299
300 def handle_input(self, line: str) -> str:
301 """Process a single def run find_nearest_graph,
302 Main REPL loop."""
303 self._discover()
304 self._init_provider()
305
306 xcept (KeyboardInterrupt, EOFError):
307 print("\nBye.")
308 break
309
310 output = self.handle_input(line)
311 if output == "__QUIT__":
312 print("Bye.")
313 break
314 if output:
315 print(output)
316 Interactive planning companion REPL for PlanOpticon."""
317
318 import logging
319 from pathlib import Path
320 from typing import List, Optional
321
322 logger = logging.getLogger(__name__)
323
324 VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
325 DOC_EXTS = {".md", ".pdf", ".docx"}
326
327
328 class CompanionREPL:
329 """Smart REPL with workspace awareness and KG querying."""
330
331 def __init__(
332 self,
333 kb_paths: Optional[List[str]] = None,
334 provider: str = "auto",
335 chat_model: Optional[str] = None,
336 ):
337 self.kg = None
338 self.query_engine = None
339 self.agent = None
340 self.provider_manager = None
341 self._kb_paths = kb_paths or []
342 self._provider_name = provider
343 self._chat_model = chat_model
344 self._videos: List[Path] = []
345 self._docs: List[Path] = []
346 self._kg_path: Optional[Path] = None
347
348 def _discover(self) -> None:
349 """Auto-discover worklanning companion REPL for PlanOpticon."""
350
351 import logging
352 from pathlib import Path
353 from typing import List, Optional
354
355 logger = logging.getLogger(__name__)
356
357 VIDEO_EXTS = {".mp4", ".mkv", ".webm"}
358 DOC_EXTS = {".md", ".pdf", ".docx"}
359
360
361 class CompanionREPL:
362 """Smart REPL with workspace awareness and KG querying."""
363
364 def __init__(
365 self,
366 kb_paths: Optional[List[str]] = None,
367 provider: str = "auto",
368 chat_model: Optional[str] = None,
369 ):
370 self.kg = None
371 self.query_engine = None
372 self.agent = None
373 self.provider_manager = None
374 self._kb_paths = kb_paths or []
375 self._provider_name = provider
376 self._chat_model = chat_model
377 self._videos: List[Path] = []
378 self._docs: List[Path]
--- a/video_processor/exchange.py
+++ b/video_processor/exchange.py
@@ -0,0 +1,209 @@
1
+"""PlanOpticonExchange -- canonical interchange format.
2
+
3
+Every command produces it, every export adapter consumes it.
4
+"""
5
+
6
+from __future__ import annotations
7
+
8
+import json
9
+from datetime import datetime
10
+from pathlib import Path
11
+from typing import Any, Dict, List, Optional
12
+
13
+from pydantic import BaseModel, Field
14
+
15
+from video_processor.models import Entity, Relationship, SourceRecord
16
+
17
+
18
+class ArtifactMeta(BaseModel):
19
+ """Pydantic mirror of the Artifact dataclass for serialisation."""
20
+
21
+ name: str = Field(description="Artifact name")
22
+ content: str = Field(description="Generated content (markdown, json, etc.)")
23
+ artifact_type: str = Field(
24
+ description="Artifact kind: project_plan, prd, roadmap, task_list, document, issues"
25
+ )
26
+ format: str = Field(
27
+ default="markdown",
28
+ description="Content format: markdown, json, mermaid",
29
+ )
30
+ metadata: Dict[str, Any] = Field(
31
+ default_factory=dict,
32
+ description="Arbitrary key-value metadata",
33
+ )
34
+
35
+
36
+class ProjectMeta(BaseModel):
37
+ """Lightweight project descriptor embedded in an exchange payload."""
38
+
39
+ name: str = Field(description="Project name")
40
+ description: str = Field(
41
+ default="",
42
+ description="Short project description",
43
+ )
44
+ created_at: str = Field(
45
+ default_factory=lambda: datetime.now().isoformat(),
46
+ description="ISO-8601 creation timestamp",
47
+ )
48
+ updated_at: str = Field(
49
+ default_factory=lambda: datetime.now().isoformat(),
50
+ description="ISO-8601 last-updated timestamp",
51
+ )
52
+ tags: List[str] = Field(
53
+ default_factory=list,
54
+ description="Freeform tags for categorisation",
55
+ )
56
+
57
+
58
+class PlanOpticonExchange(BaseModel):
59
+ """Wire format for PlanOpticon data interchange.
60
+
61
+ Produced by every command, consumed by every export adapter.
62
+ """
63
+
64
+ version: str = Field(
65
+ default="1.0",
66
+ description="Schema version of this exchange payload",
67
+ )
68
+ project: ProjectMeta = Field(
69
+ description="Project-level metadata",
70
+ )
71
+ entities: List[Entity] = Field(
72
+ default_factory=list,
73
+ description="Knowledge-graph entities",
74
+ )
75
+ relationships: List[Relationship] = Field(
76
+ default_factory=list,
77
+ description="Knowledge-graph relationships",
78
+ )
79
+ artifacts: List[ArtifactMeta] = Field(
80
+ default_factory=list,
81
+ description="Generated artifacts (plans, PRDs, etc.)",
82
+ )
83
+ sources: List[SourceRecord] = Field(
84
+ default_factory=list,
85
+ description="Content-source provenance records",
86
+ )
87
+
88
+ # ------------------------------------------------------------------
89
+ # Convenience helpers
90
+ # ------------------------------------------------------------------
91
+
92
+ @classmethod
93
+ def json_schema(cls) -> Dict[str, Any]:
94
+ """Return the JSON Schema for validation / documentation."""
95
+ return cls.model_json_schema()
96
+
97
+ @classmethod
98
+ def from_knowledge_graph(
99
+ cls,
100
+ kg_data: Dict[str, Any],
101
+ *,
102
+ project_name: str = "Untitled",
103
+ project_description: str = "",
104
+ tags: Optional[List[str]] = None,
105
+ ) -> "PlanOpticonExchange":
106
+ """Build an exchange payload from a ``KnowledgeGraph.to_dict()`` dict.
107
+
108
+ The dict is expected to have ``nodes`` and ``relationships`` keys,
109
+ with an optional ``sources`` key.
110
+ """
111
+ entities = [Entity(**_normalise_entity(n)) for n in kg_data.get("nodes", [])]
112
+ relationships = [
113
+ Relationship(**_normalise_relationship(r)) for r in kg_data.get("relationships", [])
114
+ ]
115
+ sources = [SourceRecord(**s) for s in kg_data.get("sources", [])]
116
+
117
+ now = datetime.now().isoformat()
118
+ project = ProjectMeta(
119
+ name=project_name,
120
+ description=project_description,
121
+ created_at=now,
122
+ updated_at=now,
123
+ tags=tags or [],
124
+ )
125
+
126
+ return cls(
127
+ project=project,
128
+ entities=entities,
129
+ relationships=relationships,
130
+ sources=sources,
131
+ )
132
+
133
+ # ------------------------------------------------------------------
134
+ # File I/O
135
+ # ------------------------------------------------------------------
136
+
137
+ def to_file(self, path: str | Path) -> Path:
138
+ """Serialise this exchange to a JSON file."""
139
+ path = Path(path)
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+ path.write_text(self.model_dump_json(indent=2))
142
+ return path
143
+
144
+ @classmethod
145
+ def from_file(cls, path: str | Path) -> "PlanOpticonExchange":
146
+ """Deserialise an exchange from a JSON file."""
147
+ path = Path(path)
148
+ raw = json.loads(path.read_text())
149
+ return cls.model_validate(raw)
150
+
151
+ # ------------------------------------------------------------------
152
+ # Merge
153
+ # ------------------------------------------------------------------
154
+
155
+ def merge(self, other: "PlanOpticonExchange") -> None:
156
+ """Merge *other* into this exchange, deduplicating entities by name."""
157
+ existing_names = {e.name for e in self.entities}
158
+ for entity in other.entities:
159
+ if entity.name not in existing_names:
160
+ self.entities.append(entity)
161
+ existing_names.add(entity.name)
162
+
163
+ existing_rels = {(r.source, r.target, r.type) for r in self.relationships}
164
+ for rel in other.relationships:
165
+ key = (rel.source, rel.target, rel.type)
166
+ if key not in existing_rels:
167
+ self.relationships.append(rel)
168
+ existing_rels.add(key)
169
+
170
+ existing_artifact_names = {a.name for a in self.artifacts}
171
+ for artifact in other.artifacts:
172
+ if artifact.name not in existing_artifact_names:
173
+ self.artifacts.append(artifact)
174
+ existing_artifact_names.add(artifact.name)
175
+
176
+ existing_source_ids = {s.source_id for s in self.sources}
177
+ for source in other.sources:
178
+ if source.source_id not in existing_source_ids:
179
+ self.sources.append(source)
180
+ existing_source_ids.add(source.source_id)
181
+
182
+ self.project.updated_at = datetime.now().isoformat()
183
+
184
+
185
+# ------------------------------------------------------------------
186
+# Internal helpers
187
+# ------------------------------------------------------------------
188
+
189
+
190
+def _normalise_entity(raw: Dict[str, Any]) -> Dict[str, Any]:
191
+ """Coerce a KG node dict into Entity-compatible kwargs."""
192
+ return {
193
+ "name": raw.get("name", raw.get("id", "")),
194
+ "type": raw.get("type", "concept"),
195
+ "descriptions": list(raw.get("descriptions", [])),
196
+ "source": raw.get("source"),
197
+ "occurrences": raw.get("occurrences", []),
198
+ }
199
+
200
+
201
+def _normalise_relationship(raw: Dict[str, Any]) -> Dict[str, Any]:
202
+ """Coerce a KG relationship dict into Relationship-compatible kwargs."""
203
+ return {
204
+ "source": raw.get("source", ""),
205
+ "target": raw.get("target", ""),
206
+ "type": raw.get("type", "related_to"),
207
+ "content_source": raw.get("content_source"),
208
+ "timestamp": raw.get("timestamp"),
209
+ }
--- a/video_processor/exchange.py
+++ b/video_processor/exchange.py
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/exchange.py
+++ b/video_processor/exchange.py
@@ -0,0 +1,209 @@
1 """PlanOpticonExchange -- canonical interchange format.
2
3 Every command produces it, every export adapter consumes it.
4 """
5
6 from __future__ import annotations
7
8 import json
9 from datetime import datetime
10 from pathlib import Path
11 from typing import Any, Dict, List, Optional
12
13 from pydantic import BaseModel, Field
14
15 from video_processor.models import Entity, Relationship, SourceRecord
16
17
18 class ArtifactMeta(BaseModel):
19 """Pydantic mirror of the Artifact dataclass for serialisation."""
20
21 name: str = Field(description="Artifact name")
22 content: str = Field(description="Generated content (markdown, json, etc.)")
23 artifact_type: str = Field(
24 description="Artifact kind: project_plan, prd, roadmap, task_list, document, issues"
25 )
26 format: str = Field(
27 default="markdown",
28 description="Content format: markdown, json, mermaid",
29 )
30 metadata: Dict[str, Any] = Field(
31 default_factory=dict,
32 description="Arbitrary key-value metadata",
33 )
34
35
36 class ProjectMeta(BaseModel):
37 """Lightweight project descriptor embedded in an exchange payload."""
38
39 name: str = Field(description="Project name")
40 description: str = Field(
41 default="",
42 description="Short project description",
43 )
44 created_at: str = Field(
45 default_factory=lambda: datetime.now().isoformat(),
46 description="ISO-8601 creation timestamp",
47 )
48 updated_at: str = Field(
49 default_factory=lambda: datetime.now().isoformat(),
50 description="ISO-8601 last-updated timestamp",
51 )
52 tags: List[str] = Field(
53 default_factory=list,
54 description="Freeform tags for categorisation",
55 )
56
57
58 class PlanOpticonExchange(BaseModel):
59 """Wire format for PlanOpticon data interchange.
60
61 Produced by every command, consumed by every export adapter.
62 """
63
64 version: str = Field(
65 default="1.0",
66 description="Schema version of this exchange payload",
67 )
68 project: ProjectMeta = Field(
69 description="Project-level metadata",
70 )
71 entities: List[Entity] = Field(
72 default_factory=list,
73 description="Knowledge-graph entities",
74 )
75 relationships: List[Relationship] = Field(
76 default_factory=list,
77 description="Knowledge-graph relationships",
78 )
79 artifacts: List[ArtifactMeta] = Field(
80 default_factory=list,
81 description="Generated artifacts (plans, PRDs, etc.)",
82 )
83 sources: List[SourceRecord] = Field(
84 default_factory=list,
85 description="Content-source provenance records",
86 )
87
88 # ------------------------------------------------------------------
89 # Convenience helpers
90 # ------------------------------------------------------------------
91
92 @classmethod
93 def json_schema(cls) -> Dict[str, Any]:
94 """Return the JSON Schema for validation / documentation."""
95 return cls.model_json_schema()
96
97 @classmethod
98 def from_knowledge_graph(
99 cls,
100 kg_data: Dict[str, Any],
101 *,
102 project_name: str = "Untitled",
103 project_description: str = "",
104 tags: Optional[List[str]] = None,
105 ) -> "PlanOpticonExchange":
106 """Build an exchange payload from a ``KnowledgeGraph.to_dict()`` dict.
107
108 The dict is expected to have ``nodes`` and ``relationships`` keys,
109 with an optional ``sources`` key.
110 """
111 entities = [Entity(**_normalise_entity(n)) for n in kg_data.get("nodes", [])]
112 relationships = [
113 Relationship(**_normalise_relationship(r)) for r in kg_data.get("relationships", [])
114 ]
115 sources = [SourceRecord(**s) for s in kg_data.get("sources", [])]
116
117 now = datetime.now().isoformat()
118 project = ProjectMeta(
119 name=project_name,
120 description=project_description,
121 created_at=now,
122 updated_at=now,
123 tags=tags or [],
124 )
125
126 return cls(
127 project=project,
128 entities=entities,
129 relationships=relationships,
130 sources=sources,
131 )
132
133 # ------------------------------------------------------------------
134 # File I/O
135 # ------------------------------------------------------------------
136
137 def to_file(self, path: str | Path) -> Path:
138 """Serialise this exchange to a JSON file."""
139 path = Path(path)
140 path.parent.mkdir(parents=True, exist_ok=True)
141 path.write_text(self.model_dump_json(indent=2))
142 return path
143
144 @classmethod
145 def from_file(cls, path: str | Path) -> "PlanOpticonExchange":
146 """Deserialise an exchange from a JSON file."""
147 path = Path(path)
148 raw = json.loads(path.read_text())
149 return cls.model_validate(raw)
150
151 # ------------------------------------------------------------------
152 # Merge
153 # ------------------------------------------------------------------
154
155 def merge(self, other: "PlanOpticonExchange") -> None:
156 """Merge *other* into this exchange, deduplicating entities by name."""
157 existing_names = {e.name for e in self.entities}
158 for entity in other.entities:
159 if entity.name not in existing_names:
160 self.entities.append(entity)
161 existing_names.add(entity.name)
162
163 existing_rels = {(r.source, r.target, r.type) for r in self.relationships}
164 for rel in other.relationships:
165 key = (rel.source, rel.target, rel.type)
166 if key not in existing_rels:
167 self.relationships.append(rel)
168 existing_rels.add(key)
169
170 existing_artifact_names = {a.name for a in self.artifacts}
171 for artifact in other.artifacts:
172 if artifact.name not in existing_artifact_names:
173 self.artifacts.append(artifact)
174 existing_artifact_names.add(artifact.name)
175
176 existing_source_ids = {s.source_id for s in self.sources}
177 for source in other.sources:
178 if source.source_id not in existing_source_ids:
179 self.sources.append(source)
180 existing_source_ids.add(source.source_id)
181
182 self.project.updated_at = datetime.now().isoformat()
183
184
185 # ------------------------------------------------------------------
186 # Internal helpers
187 # ------------------------------------------------------------------
188
189
190 def _normalise_entity(raw: Dict[str, Any]) -> Dict[str, Any]:
191 """Coerce a KG node dict into Entity-compatible kwargs."""
192 return {
193 "name": raw.get("name", raw.get("id", "")),
194 "type": raw.get("type", "concept"),
195 "descriptions": list(raw.get("descriptions", [])),
196 "source": raw.get("source"),
197 "occurrences": raw.get("occurrences", []),
198 }
199
200
201 def _normalise_relationship(raw: Dict[str, Any]) -> Dict[str, Any]:
202 """Coerce a KG relationship dict into Relationship-compatible kwargs."""
203 return {
204 "source": raw.get("source", ""),
205 "target": raw.get("target", ""),
206 "type": raw.get("type", "related_to"),
207 "content_source": raw.get("content_source"),
208 "timestamp": raw.get("timestamp"),
209 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button