-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathechoes_vault.py
More file actions
2454 lines (2200 loc) · 91 KB
/
Copy pathechoes_vault.py
File metadata and controls
2454 lines (2200 loc) · 91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Deterministic local storage engine for the EchoesVault Codex plugin."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import secrets
import shlex
import subprocess
import sys
import tempfile
import time
import unicodedata
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Iterator, Optional
MANAGED_ADAPTER_VERSION = "1.1.1"
ENGINE_VERSION = "1.1.1"
PROTOCOL_VERSION = "1.0.0"
SCHEMA_VERSION = 3
STATE_VERSION = 4
VAULT_DIRNAME = "EchoesVault"
RUNTIME_DIRNAME = ".echoes-vault"
RUNTIME_FILENAME = "echoes_vault.py"
STATE_RELATIVE_PATH = Path(RUNTIME_DIRNAME) / "state.json"
LOCK_RELATIVE_PATH = Path(RUNTIME_DIRNAME) / "lock"
OPENCODE_STATE_RELATIVE_PATH = Path(".opencode") / "echoes-state.json"
LEGACY_STATE_RELATIVE_PATH = Path(".codex") / "echoes-vault-state.json"
MARKER_FILENAME = ".echoes-vault.json"
PROTOCOL_FILENAME = "AGENT_PROTOCOL.md"
SUMMARY_MAX_LENGTH = 160
LOCK_WAIT_SECONDS = 8.0
STALE_LOCK_SECONDS = 60.0
REQUIRED_FRONTMATTER = ("type", "stack", "status", "summary")
INDEX_ENTRY_RE = re.compile(r"^- \[\[([^\]]+)\]\]:\s*(.*)$")
ENTRY_HEADER_RE = re.compile(r"^### (?:Scratchpad|Session) — ", flags=re.MULTILINE)
CONFLICT_MARKER_RE = re.compile(
r"^(?:<<<<<<<(?: .*)?|=======|>>>>>>>(?: .*)?)$", flags=re.MULTILINE
)
DEFAULT_INDEX = """# EchoesVault Index
<!-- Generated by EchoesVault. Do not edit manually. -->
This registry tracks all structured pages in the project knowledge vault.
## Pages
"""
VAULT_MARKER = {
"schemaVersion": SCHEMA_VERSION,
"protocolVersion": PROTOCOL_VERSION,
"generatedIndex": True,
"dailyLayout": "unique-files-v1",
"runtime": f"{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}",
"requiredFrontmatter": list(REQUIRED_FRONTMATTER),
}
AGENT_GUIDE_START = "<!-- echoes-vault:start -->"
AGENT_GUIDE_END = "<!-- echoes-vault:end -->"
AGENT_GUIDE_BLOCK = f"""{AGENT_GUIDE_START}
## EchoesVault project memory
This repository uses the agent-neutral EchoesVault protocol {PROTOCOL_VERSION}.
Managed adapter version: {MANAGED_ADAPTER_VERSION}. Reference engine: {ENGINE_VERSION}.
Before accessing persistent project memory, read `EchoesVault/{PROTOCOL_FILENAME}`. Use the project
runtime with `--workspace . --agent <agent-name> --adapter-version <adapter-version> <command>` for
all mutations.
Never edit `EchoesVault/index.md` or append to a shared date-level daily file manually.
Use `status` or `inspect` for read-only health checks; use `hydrate` only to refresh ignored local
state and the generated index. Final session saving requires an explicit user request.
{AGENT_GUIDE_END}"""
AGENT_ADAPTER_SKILL = f"""---
name: echoes-vault
description: Use repository-local EchoesVault memory when asked to initialize, restore, search, remember, document, inspect status, or explicitly save a session.
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
# EchoesVault adapter
Read `EchoesVault/{PROTOCOL_FILENAME}` before the first vault operation in a session. Run the
portable engine with:
```text
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent <agent-name> --adapter-version {MANAGED_ADAPTER_VERSION} <command>
```
Use `init`, `inspect`, `hydrate`, `start --recent 3`, `status --format card`, `search`, `append`,
`hash`, `upsert`, and `end --confirm-explicit-user-end` according to the protocol. Pass write
payloads as JSON through stdin or a temporary JSON file. Never interpolate Markdown into a shell
command.
Do not use legacy EchoesVault tools that directly edit `index.md` or append to
`daily/YYYY-MM-DD.md`. Never finalize memory unless the user explicitly asks to end or save the
session.
"""
AGENT_PROTOCOL = f"""# EchoesVault Agent Protocol
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
Reference engine: `{ENGINE_VERSION}`
EchoesVault is repository-local, agent-neutral project memory. Codex, OpenCode, Claude, and other
agents must use the same portable storage engine and the rules below.
## Compatibility
- Protocol version: `{PROTOCOL_VERSION}`
- Runtime: `{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}`
- Engine version: `{ENGINE_VERSION}`
- Initialization marker: `EchoesVault/{MARKER_FILENAME}`
- Source of truth: `EchoesVault/pages/*.md` and `EchoesVault/daily/**/*.md`
- Generated local view: `EchoesVault/index.md`
Before writing, verify that the marker's `protocolVersion` equals the runtime protocol version.
Stop on an unsupported version; never guess a migration or write through an adapter that bypasses
the portable runtime.
## Required behavior
1. Invoke `{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}` for every mutation, passing `--workspace .`,
`--agent <agent-name>`, `--adapter-version <adapter-version>`, and the command.
2. Read before updating an existing page and use `hash <filename>` immediately before `upsert`.
3. Every page must begin with frontmatter containing `type`, `stack`, `status`, and `summary`.
4. `summary` must be non-empty, single-line, and no longer than {SUMMARY_MAX_LENGTH} characters.
5. Never edit `index.md`; the runtime derives it deterministically from page metadata.
6. Never append to a shared `daily/YYYY-MM-DD.md`; the runtime creates one unique file per entry.
7. Store durable technical facts, decisions, contracts, verified fixes, blockers, and next steps,
not transcripts.
8. Deprecate instead of deleting. Use `status: deprecated`, a warning in the body, and a link to
the replacement.
9. Finalize memory only after an explicit user request to end, wrap up, finalize, or save the
session.
10. On a concurrency error, reread, reconcile, obtain a new hash, and retry. On conflict markers or
an unsupported protocol, stop and report the problem.
## Page format
```yaml
---
type: architecture
stack: [python]
status: active
summary: Authentication boundaries and token flow.
---
```
Use `[[page-slug]]` for knowledge links and `![[asset.png]]` for files in `EchoesVault/assets/`.
## Command contract
- `init`: idempotently initialize or migrate the vault and install agent adapters.
- `migrate`: explicitly migrate a recognized legacy vault.
- `upgrade`: explicitly upgrade the project runtime and tracked adapters.
- `protocol`: report the supported protocol and managed paths.
- `configure-agents`: repair protocol documentation and agent adapters without changing knowledge.
- `inspect`: report health without writing any file.
- `hydrate`: refresh only ignored `index.md` and `state.json`.
- `status --format card`: read-only alias for `inspect` with a compact card.
- `start --recent 3`: restore the generated index and latest session entries.
- `search <query>`: search page bodies without loading the whole vault.
- `append --payload -`: write `{{"entry": "...", "agent": "optional-name"}}` to a unique log.
- `hash <filename>`: obtain `expectedSha256` before updating an existing page.
- `upsert --payload -`: create or replace one complete page.
- `end --confirm-explicit-user-end --payload -`: explicitly save a final summary and page updates.
- `rebuild-index`: validate page metadata and reconstruct the local generated index.
An `upsert` page payload contains `filename`, complete `content`, and `expectedSha256` for an
existing page. An `end` payload contains `dailySummary`, a `pages` array, and optional `agent`.
## Git contract
Commit the marker, protocol, portable runtime, agent adapters, pages, unique daily files, assets,
and raw sources. Do not commit `EchoesVault/index.md`, `{RUNTIME_DIRNAME}/state.json`,
`{RUNTIME_DIRNAME}/lock`, `.opencode/echoes-state.json`, or
`.codex/echoes-vault-state.json`.
Different pages and unique daily files normally merge cleanly. If two branches edit the same page,
resolve the Markdown conflict manually, retain valid frontmatter, remove all conflict markers, and
run `hydrate`, followed by read-only `status`.
"""
OPENCODE_COMMANDS = {
"echoes-init.md": f"""---
description: Initialize or upgrade the agent-neutral EchoesVault
agent: build
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
Read `EchoesVault/{PROTOCOL_FILENAME}` when present, then initialize with:
<echoes_result>
!`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} init`
</echoes_result>
Read the generated index. Briefly report the protocol version, installed agent adapters, and known
concepts. Do not edit the index manually.
""",
"echoes-start.md": f"""---
description: Restore context through the agent-neutral EchoesVault runtime
agent: build
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
Read `EchoesVault/{PROTOCOL_FILENAME}`, then analyze this runtime-produced context:
<echoes_context>
!`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} start --recent 3`
</echoes_context>
Summarize completed outcomes, blockers, and immediate next steps. Use targeted search for details.
""",
"echoes-status.md": f"""---
description: Show agent-neutral EchoesVault health and integrity
agent: build
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
Return this card without inspecting the architectural meaning of pages:
<echoes_status>
!`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} status --format card`
</echoes_status>
""",
"echoes-end.md": f"""---
description: Explicitly distill and save the session through the agent-neutral EchoesVault runtime
agent: build
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
This command is the user's explicit request to finalize memory. Read
`EchoesVault/{PROTOCOL_FILENAME}`, search and read relevant existing pages, obtain hashes for every
existing page update, and distill outcomes, blockers, decisions, and next steps rather than a
transcript. Submit a JSON payload with `dailySummary`, `agent: "opencode"`, and `pages` to:
```text
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} end \\
--confirm-explicit-user-end --payload -
```
Never edit `index.md`, use legacy index mutation arguments, or claim success if the runtime rejects
the payload.
""",
}
LEGACY_OPENCODE_SKILLS = {
"echoes-append-to-daily-log": {
"signatures": (
"echoes_append_to_daily_log",
"EchoesVault/daily/YYYY-MM-DD.md",
),
"content": f"""---
name: echoes-append-to-daily-log
description: Redirect legacy OpenCode scratchpad writes to the shared EchoesVault runtime.
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
# EchoesVault legacy append redirect
Do not call the legacy `echoes_append_to_daily_log` tool and do not append to a shared date-level
file. Send `{{"entry": "...", "agent": "opencode"}}` as JSON through stdin to:
```text
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} append --payload -
```
""",
},
"echoes-search-vault-pages": {
"signatures": (
"echoes_search_vault_pages",
"Read-Before-Write",
),
"content": f"""---
name: echoes-search-vault-pages
description: Redirect legacy OpenCode search to the shared EchoesVault runtime.
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
# EchoesVault legacy search redirect
Do not call the legacy `echoes_search_vault_pages` tool. Search through the project runtime:
```text
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} search <specific-query>
```
Read only the relevant returned pages and follow replacement links from deprecated pages.
""",
},
"echoes-create-or-update-page": {
"signatures": (
"echoes_create_or_update_page",
"automatically updating the index",
),
"content": f"""---
name: echoes-create-or-update-page
description: Redirect legacy OpenCode page writes to the shared EchoesVault runtime.
---
<!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
<!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
# EchoesVault legacy page redirect
Do not call the legacy `echoes_create_or_update_page` tool and never edit `index.md` directly.
Read an existing page, obtain its hash, then submit the complete page as JSON through stdin:
```text
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} hash <filename>
python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
--agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} upsert --payload -
```
""",
},
}
class EchoesError(RuntimeError):
"""An expected, user-actionable vault error."""
def now() -> datetime:
return datetime.now().astimezone()
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def timestamp() -> str:
return now().isoformat(timespec="seconds")
def today() -> str:
return now().date().isoformat()
def utc_today() -> str:
return utc_now().date().isoformat()
def resolve_workspace(value: Optional[str]) -> Path:
candidate = Path(value or os.getcwd()).expanduser().resolve()
if not candidate.is_dir():
raise EchoesError(f"Workspace is not a directory: {candidate}")
try:
result = subprocess.run(
["git", "-C", str(candidate), "rev-parse", "--show-toplevel"],
check=True,
capture_output=True,
text=True,
timeout=3,
)
root = Path(result.stdout.strip()).resolve()
if root.is_dir():
return root
except (FileNotFoundError, subprocess.SubprocessError):
pass
return candidate
def vault_paths(workspace: Path) -> dict[str, Path]:
vault = workspace / VAULT_DIRNAME
paths = {
"workspace": workspace,
"vault": vault,
"raw": vault / "raw",
"pages": vault / "pages",
"daily": vault / "daily",
"assets": vault / "assets",
"index": vault / "index.md",
"marker": vault / MARKER_FILENAME,
"protocol": vault / PROTOCOL_FILENAME,
"vaultIgnore": vault / ".gitignore",
"state": workspace / STATE_RELATIVE_PATH,
"openCodeState": workspace / OPENCODE_STATE_RELATIVE_PATH,
"legacyState": workspace / LEGACY_STATE_RELATIVE_PATH,
"lock": workspace / LOCK_RELATIVE_PATH,
"runtimeDir": workspace / RUNTIME_DIRNAME,
"runtime": workspace / RUNTIME_DIRNAME / RUNTIME_FILENAME,
"runtimeIgnore": workspace / RUNTIME_DIRNAME / ".gitignore",
"agentsGuide": workspace / "AGENTS.md",
"claudeGuide": workspace / "CLAUDE.md",
"claudeSkill": workspace / ".claude" / "skills" / "echoes-vault" / "SKILL.md",
"openCodeSkill": workspace / ".opencode" / "skills" / "echoes-vault" / "SKILL.md",
"openCodeCommands": workspace / ".opencode" / "commands",
}
for key, candidate in paths.items():
if key == "workspace":
continue
try:
candidate.resolve(strict=False).relative_to(workspace)
except ValueError as exc:
raise EchoesError(
f"Managed path escapes the workspace through a symlink: {candidate}"
) from exc
return paths
def default_state() -> dict[str, Any]:
return {
"version": STATE_VERSION,
"protocolVersion": PROTOCOL_VERSION,
"engineVersion": ENGINE_VERSION,
"initialized": False,
"session": {
"started": False,
"saved": False,
"lastStart": None,
"lastSave": None,
},
"stats": {"totalPages": 0, "totalDailyLogs": 0, "deprecatedPages": 0},
"lastWriter": {"agent": None, "adapterVersion": None},
}
def merge_state(raw: Any, legacy_agent: Optional[str] = None) -> dict[str, Any]:
state = default_state()
if not isinstance(raw, dict):
return state
state["initialized"] = bool(raw.get("initialized", False))
session = raw.get("session")
if isinstance(session, dict):
for key in ("started", "saved", "lastStart", "lastSave"):
if key in session:
state["session"][key] = session[key]
last_writer = raw.get("lastWriter")
if isinstance(last_writer, dict):
agent = last_writer.get("agent")
adapter_version = last_writer.get("adapterVersion")
state["lastWriter"] = {
"agent": agent if isinstance(agent, str) and agent else None,
"adapterVersion": (
adapter_version
if isinstance(adapter_version, str) and adapter_version
else None
),
}
elif legacy_agent:
legacy_version = raw.get("pluginVersion")
state["lastWriter"] = {
"agent": legacy_agent,
"adapterVersion": legacy_version if isinstance(legacy_version, str) else None,
}
state["engineVersion"] = ENGINE_VERSION
state["protocolVersion"] = PROTOCOL_VERSION
return state
def read_state(paths: dict[str, Path]) -> dict[str, Any]:
candidates = (
(paths["state"], None),
(paths["openCodeState"], "opencode"),
(paths["legacyState"], "codex"),
)
for candidate, legacy_agent in candidates:
try:
return merge_state(
json.loads(candidate.read_text(encoding="utf-8")), legacy_agent
)
except (FileNotFoundError, json.JSONDecodeError, OSError):
continue
return default_state()
def atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
raise EchoesError(f"Refusing to replace a symbolic link: {path}")
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def append_ignore_lines(path: Path, lines: tuple[str, ...]) -> None:
if path.is_symlink():
raise EchoesError(f"Refusing to update a symbolic-link ignore file: {path}")
try:
existing = path.read_text(encoding="utf-8")
except FileNotFoundError:
existing = ""
except OSError as exc:
raise EchoesError(f"Cannot read ignore file {path}: {exc}") from exc
present = set(existing.splitlines())
missing = [line for line in lines if line not in present]
if not missing:
return
prefix = existing.rstrip()
block = "\n".join(missing)
atomic_write(path, f"{prefix}\n{block}\n" if prefix else f"{block}\n")
def ensure_ignore_rules(paths: dict[str, Path]) -> None:
append_ignore_lines(
paths["vaultIgnore"],
("# Generated locally by EchoesVault", "/index.md"),
)
append_ignore_lines(
paths["runtimeIgnore"],
(
"# EchoesVault runtime files",
"/state.json",
"/lock",
),
)
def version_tuple(content: str, constant: str) -> Optional[tuple[int, int, int]]:
match = re.search(
rf'^{re.escape(constant)} = "(\d+)\.(\d+)\.(\d+)"$',
content,
re.MULTILINE,
)
if not match:
return None
return tuple(int(part) for part in match.groups())
def engine_version_from_content(content: str) -> Optional[tuple[int, int, int]]:
return version_tuple(content, "ENGINE_VERSION") or version_tuple(
content, "PLUGIN_VERSION"
)
def protocol_version_from_content(content: str) -> Optional[tuple[int, int, int]]:
return version_tuple(content, "PROTOCOL_VERSION")
def ensure_portable_runtime(paths: dict[str, Path]) -> bool:
source = Path(__file__).resolve()
target = paths["runtime"]
if target.is_symlink():
raise EchoesError(f"Refusing to replace a symbolic-link runtime: {target}")
if source == target.resolve(strict=False):
return False
source_content = source.read_text(encoding="utf-8")
if target.exists():
try:
target_content = target.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise EchoesError(f"Cannot read portable runtime {target}: {exc}") from exc
target_version = engine_version_from_content(target_content)
source_version = engine_version_from_content(source_content)
if target_version is None or source_version is None:
raise EchoesError(
f"Refusing to overwrite an unrecognized portable runtime: {target}"
)
source_protocol = protocol_version_from_content(source_content)
target_protocol = protocol_version_from_content(target_content)
if source_protocol is None or target_protocol is None:
raise EchoesError(
f"Refusing to overwrite a runtime with unknown protocol metadata: {target}"
)
if source_protocol != target_protocol:
raise EchoesError(
f"Refusing to replace runtime protocol {target_protocol} with "
f"incompatible protocol {source_protocol}."
)
if target_version > source_version:
return False
if target_version == source_version and target_content == source_content:
return False
atomic_write(target, source_content)
return True
def write_generated_file(
path: Path, content: str, marker: str, refuse_unknown: bool = True
) -> bool:
if path.is_symlink():
raise EchoesError(f"Refusing to replace a symbolic link: {path}")
try:
current = path.read_text(encoding="utf-8")
except FileNotFoundError:
current = ""
except (OSError, UnicodeError) as exc:
raise EchoesError(f"Cannot read managed file {path}: {exc}") from exc
if current == content:
return False
if current and marker not in current and refuse_unknown:
raise EchoesError(
f"Refusing to overwrite non-EchoesVault file: {path}. Move it or add the protocol manually."
)
if current and marker not in current:
return False
atomic_write(path, content)
return True
def write_opencode_command(path: Path, content: str, marker: str) -> bool:
if path.is_symlink():
raise EchoesError(f"Refusing to replace a symbolic-link OpenCode command: {path}")
try:
current = path.read_text(encoding="utf-8")
except FileNotFoundError:
current = ""
except (OSError, UnicodeError) as exc:
raise EchoesError(f"Cannot read OpenCode command {path}: {exc}") from exc
if current == content:
return False
legacy_signatures = (
"echoes_activate_vault",
"echoes_start_session",
"commit_memory_to_echoes_vault",
"SYSTEM MESSAGE: Vault Status Report",
)
if current and marker not in current and not any(
signature in current for signature in legacy_signatures
):
return False
atomic_write(path, content)
return True
def reconcile_legacy_opencode_skills(
paths: dict[str, Path], protocol_marker: str
) -> tuple[list[str], list[str]]:
redirected: list[str] = []
conflicts: list[str] = []
skill_root = paths["workspace"] / ".opencode" / "skills"
for name, definition in LEGACY_OPENCODE_SKILLS.items():
skill_path = skill_root / name / "SKILL.md"
relative = str(skill_path.relative_to(paths["workspace"]))
if not skill_path.exists():
continue
if skill_path.is_symlink() or not skill_path.is_file():
conflicts.append(relative)
continue
try:
current = skill_path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
conflicts.append(relative)
continue
replacement = definition["content"]
signatures = definition["signatures"]
owned = protocol_marker in current and "Legacy redirect" in current
recognized_legacy = any(signature in current for signature in signatures)
if not owned and not recognized_legacy:
conflicts.append(relative)
continue
if current != replacement:
atomic_write(skill_path, replacement)
redirected.append(relative)
return redirected, conflicts
def adapter_configuration_conflicts(paths: dict[str, Path]) -> list[str]:
protocol_marker = f"Generated by EchoesVault protocol {PROTOCOL_VERSION}"
conflicts: list[str] = []
managed = [paths["claudeSkill"], paths["openCodeSkill"]]
managed.extend(
paths["openCodeCommands"] / filename for filename in OPENCODE_COMMANDS
)
for path in managed:
if not path.exists():
continue
relative = str(path.relative_to(paths["workspace"]))
if path.is_symlink() or not path.is_file():
conflicts.append(relative)
continue
try:
if protocol_marker not in path.read_text(encoding="utf-8"):
conflicts.append(relative)
except (OSError, UnicodeError):
conflicts.append(relative)
skill_root = paths["workspace"] / ".opencode" / "skills"
for name in LEGACY_OPENCODE_SKILLS:
skill_path = skill_root / name / "SKILL.md"
if not skill_path.exists():
continue
relative = str(skill_path.relative_to(paths["workspace"]))
if skill_path.is_symlink() or not skill_path.is_file():
conflicts.append(relative)
continue
try:
content = skill_path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
conflicts.append(relative)
continue
if protocol_marker not in content or "Legacy redirect" not in content:
conflicts.append(relative)
return sorted(set(conflicts))
def ensure_root_guide(path: Path) -> bool:
if path.is_symlink():
raise EchoesError(f"Refusing to update a symbolic-link agent guide: {path}")
try:
current = path.read_text(encoding="utf-8")
except FileNotFoundError:
current = ""
except (OSError, UnicodeError) as exc:
raise EchoesError(f"Cannot read agent guide {path}: {exc}") from exc
start_count = current.count(AGENT_GUIDE_START)
end_count = current.count(AGENT_GUIDE_END)
if start_count != end_count or start_count > 1:
raise EchoesError(f"Malformed EchoesVault managed block in {path}.")
if start_count == 1:
pattern = re.compile(
re.escape(AGENT_GUIDE_START) + r".*?" + re.escape(AGENT_GUIDE_END),
re.DOTALL,
)
updated = pattern.sub(AGENT_GUIDE_BLOCK, current)
else:
prefix = current.rstrip()
updated = f"{prefix}\n\n{AGENT_GUIDE_BLOCK}\n" if prefix else AGENT_GUIDE_BLOCK + "\n"
if updated == current:
return False
atomic_write(path, updated)
return True
def configure_agent_adapters(paths: dict[str, Path]) -> dict[str, Any]:
ensure_ignore_rules(paths)
runtime_updated = ensure_portable_runtime(paths)
protocol_marker = f"Generated by EchoesVault protocol {PROTOCOL_VERSION}"
protocol_updated = write_generated_file(
paths["protocol"], AGENT_PROTOCOL, protocol_marker
)
guides_updated = [
str(path.relative_to(paths["workspace"]))
for path in (paths["agentsGuide"], paths["claudeGuide"])
if ensure_root_guide(path)
]
skills_updated = [
str(path.relative_to(paths["workspace"]))
for path in (paths["claudeSkill"], paths["openCodeSkill"])
if write_generated_file(
path, AGENT_ADAPTER_SKILL, protocol_marker, refuse_unknown=False
)
]
commands_updated = [
str(path.relative_to(paths["workspace"]))
for filename, content in OPENCODE_COMMANDS.items()
for path in (paths["openCodeCommands"] / filename,)
if write_opencode_command(path, content, protocol_marker)
]
legacy_skills_redirected, adapter_conflicts = reconcile_legacy_opencode_skills(
paths, protocol_marker
)
return {
"protocolVersion": PROTOCOL_VERSION,
"engineVersion": ENGINE_VERSION,
"managedAdapterVersion": MANAGED_ADAPTER_VERSION,
"codexAdapterVersion": MANAGED_ADAPTER_VERSION,
"runtimeUpdated": runtime_updated,
"protocolUpdated": protocol_updated,
"guidesUpdated": guides_updated,
"skillsUpdated": skills_updated,
"commandsUpdated": commands_updated,
"legacySkillsRedirected": legacy_skills_redirected,
"adapterConflicts": adapter_conflicts,
}
def read_marker(paths: dict[str, Path]) -> Optional[dict[str, Any]]:
try:
marker = json.loads(paths["marker"].read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, json.JSONDecodeError) as exc:
raise EchoesError(f"Invalid EchoesVault marker: {exc}") from exc
if not isinstance(marker, dict):
raise EchoesError("Invalid EchoesVault marker: root must be an object.")
return marker
def ensure_protocol_marker(paths: dict[str, Path]) -> bool:
marker = read_marker(paths)
if marker is not None:
version = marker.get("protocolVersion")
if version not in (None, PROTOCOL_VERSION):
raise EchoesError(
f"Unsupported EchoesVault protocol {version!r}; this runtime supports "
f"{PROTOCOL_VERSION}. Upgrade the agent adapter before writing."
)
if marker == VAULT_MARKER:
return False
atomic_write(
paths["marker"], json.dumps(VAULT_MARKER, ensure_ascii=False, indent=2) + "\n"
)
return True
def vault_is_initialized(paths: dict[str, Path]) -> bool:
return (
paths["marker"].is_file()
or paths["index"].is_file()
or (paths["pages"].is_dir() and read_state(paths)["initialized"])
)
def ensure_structure(paths: dict[str, Path], create_marker: bool = False) -> None:
for key in ("raw", "pages", "daily", "assets"):
paths[key].mkdir(parents=True, exist_ok=True)
ensure_ignore_rules(paths)
if create_marker:
ensure_protocol_marker(paths)
def lock_is_stale(path: Path) -> bool:
try:
return time.time() - path.stat().st_mtime > STALE_LOCK_SECONDS
except OSError:
return False
@contextmanager
def vault_lock(paths: dict[str, Path]) -> Iterator[None]:
path = paths["lock"]
path.parent.mkdir(parents=True, exist_ok=True)
token = f"{os.getpid()}-{secrets.token_hex(8)}"
deadline = time.monotonic() + LOCK_WAIT_SECONDS
while True:
try:
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(token + "\n")
break
except FileExistsError:
if lock_is_stale(path):
try:
path.unlink()
except FileNotFoundError:
pass
continue
if time.monotonic() >= deadline:
raise EchoesError(
"Another EchoesVault operation is still running. Retry after it finishes."
)
time.sleep(0.05)
try:
yield
finally:
try:
if path.read_text(encoding="utf-8").strip() == token:
path.unlink()
except FileNotFoundError:
pass
def stable_name(value: str) -> str:
return unicodedata.normalize("NFC", value)
def stable_path_key(path: Path) -> tuple[str, str]:
normalized = stable_name(path.name)
return normalized.casefold(), normalized
def markdown_files(directory: Path) -> list[Path]:
if not directory.is_dir():
return []
return sorted(
(
item
for item in directory.iterdir()
if item.is_file() and not item.is_symlink() and item.suffix.casefold() == ".md"
),
key=stable_path_key,
)
def daily_markdown_files(directory: Path) -> list[Path]:
if not directory.is_dir():
return []
files = [
item
for item in directory.rglob("*.md")
if item.is_file() and not item.is_symlink()
]
return sorted(
files,
key=lambda item: stable_name(item.relative_to(directory).as_posix()),
)
def decode_scalar(value: str, key: str) -> str:
stripped = value.strip()
if not stripped:
raise EchoesError(f"YAML frontmatter key {key!r} must have an inline value.")
if stripped.startswith('"'):
try:
decoded = json.loads(stripped)
except json.JSONDecodeError as exc:
raise EchoesError(f"Invalid quoted YAML value for {key}: {exc}") from exc
if not isinstance(decoded, str):
raise EchoesError(f"YAML frontmatter key {key!r} must be a string.")
return decoded
if stripped.startswith("'") and stripped.endswith("'") and len(stripped) >= 2:
return stripped[1:-1].replace("''", "'")
return stripped
def normalize_summary(value: Any, slug: Optional[str] = None) -> str:
if not isinstance(value, str) or not value.strip():
raise EchoesError("Page summary must be a non-empty single-line string.")
candidate = value.strip()
match = INDEX_ENTRY_RE.match(candidate)
if match:
if slug is not None and match.group(1) != slug:
raise EchoesError(
f"Index link [[{match.group(1)}]] does not match page [[{slug}]]."
)
candidate = match.group(2).strip()
if "\n" in candidate or "\r" in candidate:
raise EchoesError("Page summary must fit on one line.")
summary = " ".join(candidate.split())
if not summary:
raise EchoesError("Page summary cannot be empty.")
if len(summary) > SUMMARY_MAX_LENGTH:
raise EchoesError(
f"Page summary exceeds {SUMMARY_MAX_LENGTH} characters ({len(summary)})."
)
if CONFLICT_MARKER_RE.search(summary):
raise EchoesError("Page summary contains a Git conflict marker.")
return summary
def parse_frontmatter(
content: Any, require_summary: bool = True
) -> tuple[str, dict[str, str]]:
if not isinstance(content, str) or not content.strip():
raise EchoesError("Page content must be a non-empty string.")
normalized = content.strip() + "\n"
if CONFLICT_MARKER_RE.search(normalized):
raise EchoesError("Page contains unresolved Git conflict markers.")
lines = normalized.splitlines()
if not lines or lines[0] != "---":
raise EchoesError("Every page must begin with YAML frontmatter.")
try:
closing = lines.index("---", 1)
except ValueError as exc:
raise EchoesError("YAML frontmatter is missing its closing '---'.") from exc
if closing == 1:
raise EchoesError("YAML frontmatter cannot be empty.")
raw_values: dict[str, str] = {}
valued_keys: set[str] = set()
frontmatter_lines = lines[1:closing]
for position, line in enumerate(frontmatter_lines):
if not line.strip() or line.lstrip().startswith("#") or line[0].isspace():
continue
match = re.match(r"^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$", line)
if not match:
continue
key, inline = match.group(1), match.group(2)
if key in raw_values:
raise EchoesError(f"YAML frontmatter contains duplicate key: {key}")
raw_values[key] = inline
if inline.strip():
valued_keys.add(key)
continue
for following in frontmatter_lines[position + 1 :]:
if not following.strip() or following.lstrip().startswith("#"):
continue
if following[0].isspace():
valued_keys.add(key)
break
required = REQUIRED_FRONTMATTER if require_summary else REQUIRED_FRONTMATTER[:-1]
missing = [key for key in required if key not in valued_keys]
if missing:
raise EchoesError(f"YAML frontmatter is missing required keys: {', '.join(missing)}")
metadata = {