echoes-vault-opencode 1.2.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2454 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic local storage engine for the EchoesVault Codex plugin."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import re
11
+ import secrets
12
+ import shlex
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import unicodedata
18
+ from contextlib import contextmanager
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Iterable, Iterator, Optional
22
+
23
+
24
+ MANAGED_ADAPTER_VERSION = "1.1.1"
25
+ ENGINE_VERSION = "1.1.1"
26
+ PROTOCOL_VERSION = "1.0.0"
27
+ SCHEMA_VERSION = 3
28
+ STATE_VERSION = 4
29
+ VAULT_DIRNAME = "EchoesVault"
30
+ RUNTIME_DIRNAME = ".echoes-vault"
31
+ RUNTIME_FILENAME = "echoes_vault.py"
32
+ STATE_RELATIVE_PATH = Path(RUNTIME_DIRNAME) / "state.json"
33
+ LOCK_RELATIVE_PATH = Path(RUNTIME_DIRNAME) / "lock"
34
+ OPENCODE_STATE_RELATIVE_PATH = Path(".opencode") / "echoes-state.json"
35
+ LEGACY_STATE_RELATIVE_PATH = Path(".codex") / "echoes-vault-state.json"
36
+ MARKER_FILENAME = ".echoes-vault.json"
37
+ PROTOCOL_FILENAME = "AGENT_PROTOCOL.md"
38
+ SUMMARY_MAX_LENGTH = 160
39
+ LOCK_WAIT_SECONDS = 8.0
40
+ STALE_LOCK_SECONDS = 60.0
41
+ REQUIRED_FRONTMATTER = ("type", "stack", "status", "summary")
42
+ INDEX_ENTRY_RE = re.compile(r"^- \[\[([^\]]+)\]\]:\s*(.*)$")
43
+ ENTRY_HEADER_RE = re.compile(r"^### (?:Scratchpad|Session) — ", flags=re.MULTILINE)
44
+ CONFLICT_MARKER_RE = re.compile(
45
+ r"^(?:<<<<<<<(?: .*)?|=======|>>>>>>>(?: .*)?)$", flags=re.MULTILINE
46
+ )
47
+ DEFAULT_INDEX = """# EchoesVault Index
48
+
49
+ <!-- Generated by EchoesVault. Do not edit manually. -->
50
+
51
+ This registry tracks all structured pages in the project knowledge vault.
52
+
53
+ ## Pages
54
+ """
55
+ VAULT_MARKER = {
56
+ "schemaVersion": SCHEMA_VERSION,
57
+ "protocolVersion": PROTOCOL_VERSION,
58
+ "generatedIndex": True,
59
+ "dailyLayout": "unique-files-v1",
60
+ "runtime": f"{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}",
61
+ "requiredFrontmatter": list(REQUIRED_FRONTMATTER),
62
+ }
63
+
64
+ AGENT_GUIDE_START = "<!-- echoes-vault:start -->"
65
+ AGENT_GUIDE_END = "<!-- echoes-vault:end -->"
66
+ AGENT_GUIDE_BLOCK = f"""{AGENT_GUIDE_START}
67
+ ## EchoesVault project memory
68
+
69
+ This repository uses the agent-neutral EchoesVault protocol {PROTOCOL_VERSION}.
70
+ Managed adapter version: {MANAGED_ADAPTER_VERSION}. Reference engine: {ENGINE_VERSION}.
71
+
72
+ Before accessing persistent project memory, read `EchoesVault/{PROTOCOL_FILENAME}`. Use the project
73
+ runtime with `--workspace . --agent <agent-name> --adapter-version <adapter-version> <command>` for
74
+ all mutations.
75
+ Never edit `EchoesVault/index.md` or append to a shared date-level daily file manually.
76
+ Use `status` or `inspect` for read-only health checks; use `hydrate` only to refresh ignored local
77
+ state and the generated index. Final session saving requires an explicit user request.
78
+ {AGENT_GUIDE_END}"""
79
+
80
+ AGENT_ADAPTER_SKILL = f"""---
81
+ name: echoes-vault
82
+ description: Use repository-local EchoesVault memory when asked to initialize, restore, search, remember, document, inspect status, or explicitly save a session.
83
+ ---
84
+
85
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
86
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
87
+
88
+ # EchoesVault adapter
89
+
90
+ Read `EchoesVault/{PROTOCOL_FILENAME}` before the first vault operation in a session. Run the
91
+ portable engine with:
92
+
93
+ ```text
94
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
95
+ --agent <agent-name> --adapter-version {MANAGED_ADAPTER_VERSION} <command>
96
+ ```
97
+
98
+ Use `init`, `inspect`, `hydrate`, `start --recent 3`, `status --format card`, `search`, `append`,
99
+ `hash`, `upsert`, and `end --confirm-explicit-user-end` according to the protocol. Pass write
100
+ payloads as JSON through stdin or a temporary JSON file. Never interpolate Markdown into a shell
101
+ command.
102
+
103
+ Do not use legacy EchoesVault tools that directly edit `index.md` or append to
104
+ `daily/YYYY-MM-DD.md`. Never finalize memory unless the user explicitly asks to end or save the
105
+ session.
106
+ """
107
+
108
+ AGENT_PROTOCOL = f"""# EchoesVault Agent Protocol
109
+
110
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
111
+
112
+ Reference engine: `{ENGINE_VERSION}`
113
+
114
+ EchoesVault is repository-local, agent-neutral project memory. Codex, OpenCode, Claude, and other
115
+ agents must use the same portable storage engine and the rules below.
116
+
117
+ ## Compatibility
118
+
119
+ - Protocol version: `{PROTOCOL_VERSION}`
120
+ - Runtime: `{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}`
121
+ - Engine version: `{ENGINE_VERSION}`
122
+ - Initialization marker: `EchoesVault/{MARKER_FILENAME}`
123
+ - Source of truth: `EchoesVault/pages/*.md` and `EchoesVault/daily/**/*.md`
124
+ - Generated local view: `EchoesVault/index.md`
125
+
126
+ Before writing, verify that the marker's `protocolVersion` equals the runtime protocol version.
127
+ Stop on an unsupported version; never guess a migration or write through an adapter that bypasses
128
+ the portable runtime.
129
+
130
+ ## Required behavior
131
+
132
+ 1. Invoke `{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}` for every mutation, passing `--workspace .`,
133
+ `--agent <agent-name>`, `--adapter-version <adapter-version>`, and the command.
134
+ 2. Read before updating an existing page and use `hash <filename>` immediately before `upsert`.
135
+ 3. Every page must begin with frontmatter containing `type`, `stack`, `status`, and `summary`.
136
+ 4. `summary` must be non-empty, single-line, and no longer than {SUMMARY_MAX_LENGTH} characters.
137
+ 5. Never edit `index.md`; the runtime derives it deterministically from page metadata.
138
+ 6. Never append to a shared `daily/YYYY-MM-DD.md`; the runtime creates one unique file per entry.
139
+ 7. Store durable technical facts, decisions, contracts, verified fixes, blockers, and next steps,
140
+ not transcripts.
141
+ 8. Deprecate instead of deleting. Use `status: deprecated`, a warning in the body, and a link to
142
+ the replacement.
143
+ 9. Finalize memory only after an explicit user request to end, wrap up, finalize, or save the
144
+ session.
145
+ 10. On a concurrency error, reread, reconcile, obtain a new hash, and retry. On conflict markers or
146
+ an unsupported protocol, stop and report the problem.
147
+
148
+ ## Page format
149
+
150
+ ```yaml
151
+ ---
152
+ type: architecture
153
+ stack: [python]
154
+ status: active
155
+ summary: Authentication boundaries and token flow.
156
+ ---
157
+ ```
158
+
159
+ Use `[[page-slug]]` for knowledge links and `![[asset.png]]` for files in `EchoesVault/assets/`.
160
+
161
+ ## Command contract
162
+
163
+ - `init`: idempotently initialize or migrate the vault and install agent adapters.
164
+ - `migrate`: explicitly migrate a recognized legacy vault.
165
+ - `upgrade`: explicitly upgrade the project runtime and tracked adapters.
166
+ - `protocol`: report the supported protocol and managed paths.
167
+ - `configure-agents`: repair protocol documentation and agent adapters without changing knowledge.
168
+ - `inspect`: report health without writing any file.
169
+ - `hydrate`: refresh only ignored `index.md` and `state.json`.
170
+ - `status --format card`: read-only alias for `inspect` with a compact card.
171
+ - `start --recent 3`: restore the generated index and latest session entries.
172
+ - `search <query>`: search page bodies without loading the whole vault.
173
+ - `append --payload -`: write `{{"entry": "...", "agent": "optional-name"}}` to a unique log.
174
+ - `hash <filename>`: obtain `expectedSha256` before updating an existing page.
175
+ - `upsert --payload -`: create or replace one complete page.
176
+ - `end --confirm-explicit-user-end --payload -`: explicitly save a final summary and page updates.
177
+ - `rebuild-index`: validate page metadata and reconstruct the local generated index.
178
+
179
+ An `upsert` page payload contains `filename`, complete `content`, and `expectedSha256` for an
180
+ existing page. An `end` payload contains `dailySummary`, a `pages` array, and optional `agent`.
181
+
182
+ ## Git contract
183
+
184
+ Commit the marker, protocol, portable runtime, agent adapters, pages, unique daily files, assets,
185
+ and raw sources. Do not commit `EchoesVault/index.md`, `{RUNTIME_DIRNAME}/state.json`,
186
+ `{RUNTIME_DIRNAME}/lock`, `.opencode/echoes-state.json`, or
187
+ `.codex/echoes-vault-state.json`.
188
+
189
+ Different pages and unique daily files normally merge cleanly. If two branches edit the same page,
190
+ resolve the Markdown conflict manually, retain valid frontmatter, remove all conflict markers, and
191
+ run `hydrate`, followed by read-only `status`.
192
+ """
193
+
194
+ OPENCODE_COMMANDS = {
195
+ "echoes-init.md": f"""---
196
+ description: Initialize or upgrade the agent-neutral EchoesVault
197
+ agent: build
198
+ ---
199
+
200
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
201
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
202
+
203
+ Read `EchoesVault/{PROTOCOL_FILENAME}` when present, then initialize with:
204
+
205
+ <echoes_result>
206
+ !`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} init`
207
+ </echoes_result>
208
+
209
+ Read the generated index. Briefly report the protocol version, installed agent adapters, and known
210
+ concepts. Do not edit the index manually.
211
+ """,
212
+ "echoes-start.md": f"""---
213
+ description: Restore context through the agent-neutral EchoesVault runtime
214
+ agent: build
215
+ ---
216
+
217
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
218
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
219
+
220
+ Read `EchoesVault/{PROTOCOL_FILENAME}`, then analyze this runtime-produced context:
221
+
222
+ <echoes_context>
223
+ !`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} start --recent 3`
224
+ </echoes_context>
225
+
226
+ Summarize completed outcomes, blockers, and immediate next steps. Use targeted search for details.
227
+ """,
228
+ "echoes-status.md": f"""---
229
+ description: Show agent-neutral EchoesVault health and integrity
230
+ agent: build
231
+ ---
232
+
233
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
234
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
235
+
236
+ Return this card without inspecting the architectural meaning of pages:
237
+
238
+ <echoes_status>
239
+ !`python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} status --format card`
240
+ </echoes_status>
241
+ """,
242
+ "echoes-end.md": f"""---
243
+ description: Explicitly distill and save the session through the agent-neutral EchoesVault runtime
244
+ agent: build
245
+ ---
246
+
247
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
248
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. -->
249
+
250
+ This command is the user's explicit request to finalize memory. Read
251
+ `EchoesVault/{PROTOCOL_FILENAME}`, search and read relevant existing pages, obtain hashes for every
252
+ existing page update, and distill outcomes, blockers, decisions, and next steps rather than a
253
+ transcript. Submit a JSON payload with `dailySummary`, `agent: "opencode"`, and `pages` to:
254
+
255
+ ```text
256
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
257
+ --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} end \\
258
+ --confirm-explicit-user-end --payload -
259
+ ```
260
+
261
+ Never edit `index.md`, use legacy index mutation arguments, or claim success if the runtime rejects
262
+ the payload.
263
+ """,
264
+ }
265
+
266
+ LEGACY_OPENCODE_SKILLS = {
267
+ "echoes-append-to-daily-log": {
268
+ "signatures": (
269
+ "echoes_append_to_daily_log",
270
+ "EchoesVault/daily/YYYY-MM-DD.md",
271
+ ),
272
+ "content": f"""---
273
+ name: echoes-append-to-daily-log
274
+ description: Redirect legacy OpenCode scratchpad writes to the shared EchoesVault runtime.
275
+ ---
276
+
277
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
278
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
279
+
280
+ # EchoesVault legacy append redirect
281
+
282
+ Do not call the legacy `echoes_append_to_daily_log` tool and do not append to a shared date-level
283
+ file. Send `{{"entry": "...", "agent": "opencode"}}` as JSON through stdin to:
284
+
285
+ ```text
286
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
287
+ --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} append --payload -
288
+ ```
289
+ """,
290
+ },
291
+ "echoes-search-vault-pages": {
292
+ "signatures": (
293
+ "echoes_search_vault_pages",
294
+ "Read-Before-Write",
295
+ ),
296
+ "content": f"""---
297
+ name: echoes-search-vault-pages
298
+ description: Redirect legacy OpenCode search to the shared EchoesVault runtime.
299
+ ---
300
+
301
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
302
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
303
+
304
+ # EchoesVault legacy search redirect
305
+
306
+ Do not call the legacy `echoes_search_vault_pages` tool. Search through the project runtime:
307
+
308
+ ```text
309
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
310
+ --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} search <specific-query>
311
+ ```
312
+
313
+ Read only the relevant returned pages and follow replacement links from deprecated pages.
314
+ """,
315
+ },
316
+ "echoes-create-or-update-page": {
317
+ "signatures": (
318
+ "echoes_create_or_update_page",
319
+ "automatically updating the index",
320
+ ),
321
+ "content": f"""---
322
+ name: echoes-create-or-update-page
323
+ description: Redirect legacy OpenCode page writes to the shared EchoesVault runtime.
324
+ ---
325
+
326
+ <!-- Generated by EchoesVault protocol {PROTOCOL_VERSION}. Do not edit manually. -->
327
+ <!-- Managed adapter version: {MANAGED_ADAPTER_VERSION}. Legacy redirect. -->
328
+
329
+ # EchoesVault legacy page redirect
330
+
331
+ Do not call the legacy `echoes_create_or_update_page` tool and never edit `index.md` directly.
332
+ Read an existing page, obtain its hash, then submit the complete page as JSON through stdin:
333
+
334
+ ```text
335
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
336
+ --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} hash <filename>
337
+ python3 {RUNTIME_DIRNAME}/{RUNTIME_FILENAME} --workspace . \\
338
+ --agent opencode --adapter-version {MANAGED_ADAPTER_VERSION} upsert --payload -
339
+ ```
340
+ """,
341
+ },
342
+ }
343
+
344
+
345
+ class EchoesError(RuntimeError):
346
+ """An expected, user-actionable vault error."""
347
+
348
+
349
+ def now() -> datetime:
350
+ return datetime.now().astimezone()
351
+
352
+
353
+ def utc_now() -> datetime:
354
+ return datetime.now(timezone.utc)
355
+
356
+
357
+ def timestamp() -> str:
358
+ return now().isoformat(timespec="seconds")
359
+
360
+
361
+ def today() -> str:
362
+ return now().date().isoformat()
363
+
364
+
365
+ def utc_today() -> str:
366
+ return utc_now().date().isoformat()
367
+
368
+
369
+ def resolve_workspace(value: Optional[str]) -> Path:
370
+ candidate = Path(value or os.getcwd()).expanduser().resolve()
371
+ if not candidate.is_dir():
372
+ raise EchoesError(f"Workspace is not a directory: {candidate}")
373
+ try:
374
+ result = subprocess.run(
375
+ ["git", "-C", str(candidate), "rev-parse", "--show-toplevel"],
376
+ check=True,
377
+ capture_output=True,
378
+ text=True,
379
+ timeout=3,
380
+ )
381
+ root = Path(result.stdout.strip()).resolve()
382
+ if root.is_dir():
383
+ return root
384
+ except (FileNotFoundError, subprocess.SubprocessError):
385
+ pass
386
+ return candidate
387
+
388
+
389
+ def vault_paths(workspace: Path) -> dict[str, Path]:
390
+ vault = workspace / VAULT_DIRNAME
391
+ paths = {
392
+ "workspace": workspace,
393
+ "vault": vault,
394
+ "raw": vault / "raw",
395
+ "pages": vault / "pages",
396
+ "daily": vault / "daily",
397
+ "assets": vault / "assets",
398
+ "index": vault / "index.md",
399
+ "marker": vault / MARKER_FILENAME,
400
+ "protocol": vault / PROTOCOL_FILENAME,
401
+ "vaultIgnore": vault / ".gitignore",
402
+ "state": workspace / STATE_RELATIVE_PATH,
403
+ "openCodeState": workspace / OPENCODE_STATE_RELATIVE_PATH,
404
+ "legacyState": workspace / LEGACY_STATE_RELATIVE_PATH,
405
+ "lock": workspace / LOCK_RELATIVE_PATH,
406
+ "runtimeDir": workspace / RUNTIME_DIRNAME,
407
+ "runtime": workspace / RUNTIME_DIRNAME / RUNTIME_FILENAME,
408
+ "runtimeIgnore": workspace / RUNTIME_DIRNAME / ".gitignore",
409
+ "agentsGuide": workspace / "AGENTS.md",
410
+ "claudeGuide": workspace / "CLAUDE.md",
411
+ "claudeSkill": workspace / ".claude" / "skills" / "echoes-vault" / "SKILL.md",
412
+ "openCodeSkill": workspace / ".opencode" / "skills" / "echoes-vault" / "SKILL.md",
413
+ "openCodeCommands": workspace / ".opencode" / "commands",
414
+ }
415
+ for key, candidate in paths.items():
416
+ if key == "workspace":
417
+ continue
418
+ try:
419
+ candidate.resolve(strict=False).relative_to(workspace)
420
+ except ValueError as exc:
421
+ raise EchoesError(
422
+ f"Managed path escapes the workspace through a symlink: {candidate}"
423
+ ) from exc
424
+ return paths
425
+
426
+
427
+ def default_state() -> dict[str, Any]:
428
+ return {
429
+ "version": STATE_VERSION,
430
+ "protocolVersion": PROTOCOL_VERSION,
431
+ "engineVersion": ENGINE_VERSION,
432
+ "initialized": False,
433
+ "session": {
434
+ "started": False,
435
+ "saved": False,
436
+ "lastStart": None,
437
+ "lastSave": None,
438
+ },
439
+ "stats": {"totalPages": 0, "totalDailyLogs": 0, "deprecatedPages": 0},
440
+ "lastWriter": {"agent": None, "adapterVersion": None},
441
+ }
442
+
443
+
444
+ def merge_state(raw: Any, legacy_agent: Optional[str] = None) -> dict[str, Any]:
445
+ state = default_state()
446
+ if not isinstance(raw, dict):
447
+ return state
448
+ state["initialized"] = bool(raw.get("initialized", False))
449
+ session = raw.get("session")
450
+ if isinstance(session, dict):
451
+ for key in ("started", "saved", "lastStart", "lastSave"):
452
+ if key in session:
453
+ state["session"][key] = session[key]
454
+ last_writer = raw.get("lastWriter")
455
+ if isinstance(last_writer, dict):
456
+ agent = last_writer.get("agent")
457
+ adapter_version = last_writer.get("adapterVersion")
458
+ state["lastWriter"] = {
459
+ "agent": agent if isinstance(agent, str) and agent else None,
460
+ "adapterVersion": (
461
+ adapter_version
462
+ if isinstance(adapter_version, str) and adapter_version
463
+ else None
464
+ ),
465
+ }
466
+ elif legacy_agent:
467
+ legacy_version = raw.get("pluginVersion")
468
+ state["lastWriter"] = {
469
+ "agent": legacy_agent,
470
+ "adapterVersion": legacy_version if isinstance(legacy_version, str) else None,
471
+ }
472
+ state["engineVersion"] = ENGINE_VERSION
473
+ state["protocolVersion"] = PROTOCOL_VERSION
474
+ return state
475
+
476
+
477
+ def read_state(paths: dict[str, Path]) -> dict[str, Any]:
478
+ candidates = (
479
+ (paths["state"], None),
480
+ (paths["openCodeState"], "opencode"),
481
+ (paths["legacyState"], "codex"),
482
+ )
483
+ for candidate, legacy_agent in candidates:
484
+ try:
485
+ return merge_state(
486
+ json.loads(candidate.read_text(encoding="utf-8")), legacy_agent
487
+ )
488
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
489
+ continue
490
+ return default_state()
491
+
492
+
493
+ def atomic_write(path: Path, content: str) -> None:
494
+ path.parent.mkdir(parents=True, exist_ok=True)
495
+ if path.is_symlink():
496
+ raise EchoesError(f"Refusing to replace a symbolic link: {path}")
497
+ descriptor, temporary_name = tempfile.mkstemp(
498
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
499
+ )
500
+ temporary = Path(temporary_name)
501
+ try:
502
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
503
+ handle.write(content)
504
+ handle.flush()
505
+ os.fsync(handle.fileno())
506
+ os.replace(temporary, path)
507
+ finally:
508
+ if temporary.exists():
509
+ temporary.unlink()
510
+
511
+
512
+ def append_ignore_lines(path: Path, lines: tuple[str, ...]) -> None:
513
+ if path.is_symlink():
514
+ raise EchoesError(f"Refusing to update a symbolic-link ignore file: {path}")
515
+ try:
516
+ existing = path.read_text(encoding="utf-8")
517
+ except FileNotFoundError:
518
+ existing = ""
519
+ except OSError as exc:
520
+ raise EchoesError(f"Cannot read ignore file {path}: {exc}") from exc
521
+ present = set(existing.splitlines())
522
+ missing = [line for line in lines if line not in present]
523
+ if not missing:
524
+ return
525
+ prefix = existing.rstrip()
526
+ block = "\n".join(missing)
527
+ atomic_write(path, f"{prefix}\n{block}\n" if prefix else f"{block}\n")
528
+
529
+
530
+ def ensure_ignore_rules(paths: dict[str, Path]) -> None:
531
+ append_ignore_lines(
532
+ paths["vaultIgnore"],
533
+ ("# Generated locally by EchoesVault", "/index.md"),
534
+ )
535
+ append_ignore_lines(
536
+ paths["runtimeIgnore"],
537
+ (
538
+ "# EchoesVault runtime files",
539
+ "/state.json",
540
+ "/lock",
541
+ ),
542
+ )
543
+
544
+
545
+ def version_tuple(content: str, constant: str) -> Optional[tuple[int, int, int]]:
546
+ match = re.search(
547
+ rf'^{re.escape(constant)} = "(\d+)\.(\d+)\.(\d+)"$',
548
+ content,
549
+ re.MULTILINE,
550
+ )
551
+ if not match:
552
+ return None
553
+ return tuple(int(part) for part in match.groups())
554
+
555
+
556
+ def engine_version_from_content(content: str) -> Optional[tuple[int, int, int]]:
557
+ return version_tuple(content, "ENGINE_VERSION") or version_tuple(
558
+ content, "PLUGIN_VERSION"
559
+ )
560
+
561
+
562
+ def protocol_version_from_content(content: str) -> Optional[tuple[int, int, int]]:
563
+ return version_tuple(content, "PROTOCOL_VERSION")
564
+
565
+
566
+ def ensure_portable_runtime(paths: dict[str, Path]) -> bool:
567
+ source = Path(__file__).resolve()
568
+ target = paths["runtime"]
569
+ if target.is_symlink():
570
+ raise EchoesError(f"Refusing to replace a symbolic-link runtime: {target}")
571
+ if source == target.resolve(strict=False):
572
+ return False
573
+ source_content = source.read_text(encoding="utf-8")
574
+ if target.exists():
575
+ try:
576
+ target_content = target.read_text(encoding="utf-8")
577
+ except (OSError, UnicodeError) as exc:
578
+ raise EchoesError(f"Cannot read portable runtime {target}: {exc}") from exc
579
+ target_version = engine_version_from_content(target_content)
580
+ source_version = engine_version_from_content(source_content)
581
+ if target_version is None or source_version is None:
582
+ raise EchoesError(
583
+ f"Refusing to overwrite an unrecognized portable runtime: {target}"
584
+ )
585
+ source_protocol = protocol_version_from_content(source_content)
586
+ target_protocol = protocol_version_from_content(target_content)
587
+ if source_protocol is None or target_protocol is None:
588
+ raise EchoesError(
589
+ f"Refusing to overwrite a runtime with unknown protocol metadata: {target}"
590
+ )
591
+ if source_protocol != target_protocol:
592
+ raise EchoesError(
593
+ f"Refusing to replace runtime protocol {target_protocol} with "
594
+ f"incompatible protocol {source_protocol}."
595
+ )
596
+ if target_version > source_version:
597
+ return False
598
+ if target_version == source_version and target_content == source_content:
599
+ return False
600
+ atomic_write(target, source_content)
601
+ return True
602
+
603
+
604
+ def write_generated_file(
605
+ path: Path, content: str, marker: str, refuse_unknown: bool = True
606
+ ) -> bool:
607
+ if path.is_symlink():
608
+ raise EchoesError(f"Refusing to replace a symbolic link: {path}")
609
+ try:
610
+ current = path.read_text(encoding="utf-8")
611
+ except FileNotFoundError:
612
+ current = ""
613
+ except (OSError, UnicodeError) as exc:
614
+ raise EchoesError(f"Cannot read managed file {path}: {exc}") from exc
615
+ if current == content:
616
+ return False
617
+ if current and marker not in current and refuse_unknown:
618
+ raise EchoesError(
619
+ f"Refusing to overwrite non-EchoesVault file: {path}. Move it or add the protocol manually."
620
+ )
621
+ if current and marker not in current:
622
+ return False
623
+ atomic_write(path, content)
624
+ return True
625
+
626
+
627
+ def write_opencode_command(path: Path, content: str, marker: str) -> bool:
628
+ if path.is_symlink():
629
+ raise EchoesError(f"Refusing to replace a symbolic-link OpenCode command: {path}")
630
+ try:
631
+ current = path.read_text(encoding="utf-8")
632
+ except FileNotFoundError:
633
+ current = ""
634
+ except (OSError, UnicodeError) as exc:
635
+ raise EchoesError(f"Cannot read OpenCode command {path}: {exc}") from exc
636
+ if current == content:
637
+ return False
638
+ legacy_signatures = (
639
+ "echoes_activate_vault",
640
+ "echoes_start_session",
641
+ "commit_memory_to_echoes_vault",
642
+ "SYSTEM MESSAGE: Vault Status Report",
643
+ )
644
+ if current and marker not in current and not any(
645
+ signature in current for signature in legacy_signatures
646
+ ):
647
+ return False
648
+ atomic_write(path, content)
649
+ return True
650
+
651
+
652
+ def reconcile_legacy_opencode_skills(
653
+ paths: dict[str, Path], protocol_marker: str
654
+ ) -> tuple[list[str], list[str]]:
655
+ redirected: list[str] = []
656
+ conflicts: list[str] = []
657
+ skill_root = paths["workspace"] / ".opencode" / "skills"
658
+ for name, definition in LEGACY_OPENCODE_SKILLS.items():
659
+ skill_path = skill_root / name / "SKILL.md"
660
+ relative = str(skill_path.relative_to(paths["workspace"]))
661
+ if not skill_path.exists():
662
+ continue
663
+ if skill_path.is_symlink() or not skill_path.is_file():
664
+ conflicts.append(relative)
665
+ continue
666
+ try:
667
+ current = skill_path.read_text(encoding="utf-8")
668
+ except (OSError, UnicodeError):
669
+ conflicts.append(relative)
670
+ continue
671
+ replacement = definition["content"]
672
+ signatures = definition["signatures"]
673
+ owned = protocol_marker in current and "Legacy redirect" in current
674
+ recognized_legacy = any(signature in current for signature in signatures)
675
+ if not owned and not recognized_legacy:
676
+ conflicts.append(relative)
677
+ continue
678
+ if current != replacement:
679
+ atomic_write(skill_path, replacement)
680
+ redirected.append(relative)
681
+ return redirected, conflicts
682
+
683
+
684
+ def adapter_configuration_conflicts(paths: dict[str, Path]) -> list[str]:
685
+ protocol_marker = f"Generated by EchoesVault protocol {PROTOCOL_VERSION}"
686
+ conflicts: list[str] = []
687
+ managed = [paths["claudeSkill"], paths["openCodeSkill"]]
688
+ managed.extend(
689
+ paths["openCodeCommands"] / filename for filename in OPENCODE_COMMANDS
690
+ )
691
+ for path in managed:
692
+ if not path.exists():
693
+ continue
694
+ relative = str(path.relative_to(paths["workspace"]))
695
+ if path.is_symlink() or not path.is_file():
696
+ conflicts.append(relative)
697
+ continue
698
+ try:
699
+ if protocol_marker not in path.read_text(encoding="utf-8"):
700
+ conflicts.append(relative)
701
+ except (OSError, UnicodeError):
702
+ conflicts.append(relative)
703
+ skill_root = paths["workspace"] / ".opencode" / "skills"
704
+ for name in LEGACY_OPENCODE_SKILLS:
705
+ skill_path = skill_root / name / "SKILL.md"
706
+ if not skill_path.exists():
707
+ continue
708
+ relative = str(skill_path.relative_to(paths["workspace"]))
709
+ if skill_path.is_symlink() or not skill_path.is_file():
710
+ conflicts.append(relative)
711
+ continue
712
+ try:
713
+ content = skill_path.read_text(encoding="utf-8")
714
+ except (OSError, UnicodeError):
715
+ conflicts.append(relative)
716
+ continue
717
+ if protocol_marker not in content or "Legacy redirect" not in content:
718
+ conflicts.append(relative)
719
+ return sorted(set(conflicts))
720
+
721
+
722
+ def ensure_root_guide(path: Path) -> bool:
723
+ if path.is_symlink():
724
+ raise EchoesError(f"Refusing to update a symbolic-link agent guide: {path}")
725
+ try:
726
+ current = path.read_text(encoding="utf-8")
727
+ except FileNotFoundError:
728
+ current = ""
729
+ except (OSError, UnicodeError) as exc:
730
+ raise EchoesError(f"Cannot read agent guide {path}: {exc}") from exc
731
+ start_count = current.count(AGENT_GUIDE_START)
732
+ end_count = current.count(AGENT_GUIDE_END)
733
+ if start_count != end_count or start_count > 1:
734
+ raise EchoesError(f"Malformed EchoesVault managed block in {path}.")
735
+ if start_count == 1:
736
+ pattern = re.compile(
737
+ re.escape(AGENT_GUIDE_START) + r".*?" + re.escape(AGENT_GUIDE_END),
738
+ re.DOTALL,
739
+ )
740
+ updated = pattern.sub(AGENT_GUIDE_BLOCK, current)
741
+ else:
742
+ prefix = current.rstrip()
743
+ updated = f"{prefix}\n\n{AGENT_GUIDE_BLOCK}\n" if prefix else AGENT_GUIDE_BLOCK + "\n"
744
+ if updated == current:
745
+ return False
746
+ atomic_write(path, updated)
747
+ return True
748
+
749
+
750
+ def configure_agent_adapters(paths: dict[str, Path]) -> dict[str, Any]:
751
+ ensure_ignore_rules(paths)
752
+ runtime_updated = ensure_portable_runtime(paths)
753
+ protocol_marker = f"Generated by EchoesVault protocol {PROTOCOL_VERSION}"
754
+ protocol_updated = write_generated_file(
755
+ paths["protocol"], AGENT_PROTOCOL, protocol_marker
756
+ )
757
+ guides_updated = [
758
+ str(path.relative_to(paths["workspace"]))
759
+ for path in (paths["agentsGuide"], paths["claudeGuide"])
760
+ if ensure_root_guide(path)
761
+ ]
762
+ skills_updated = [
763
+ str(path.relative_to(paths["workspace"]))
764
+ for path in (paths["claudeSkill"], paths["openCodeSkill"])
765
+ if write_generated_file(
766
+ path, AGENT_ADAPTER_SKILL, protocol_marker, refuse_unknown=False
767
+ )
768
+ ]
769
+ commands_updated = [
770
+ str(path.relative_to(paths["workspace"]))
771
+ for filename, content in OPENCODE_COMMANDS.items()
772
+ for path in (paths["openCodeCommands"] / filename,)
773
+ if write_opencode_command(path, content, protocol_marker)
774
+ ]
775
+ legacy_skills_redirected, adapter_conflicts = reconcile_legacy_opencode_skills(
776
+ paths, protocol_marker
777
+ )
778
+ return {
779
+ "protocolVersion": PROTOCOL_VERSION,
780
+ "engineVersion": ENGINE_VERSION,
781
+ "managedAdapterVersion": MANAGED_ADAPTER_VERSION,
782
+ "codexAdapterVersion": MANAGED_ADAPTER_VERSION,
783
+ "runtimeUpdated": runtime_updated,
784
+ "protocolUpdated": protocol_updated,
785
+ "guidesUpdated": guides_updated,
786
+ "skillsUpdated": skills_updated,
787
+ "commandsUpdated": commands_updated,
788
+ "legacySkillsRedirected": legacy_skills_redirected,
789
+ "adapterConflicts": adapter_conflicts,
790
+ }
791
+
792
+
793
+ def read_marker(paths: dict[str, Path]) -> Optional[dict[str, Any]]:
794
+ try:
795
+ marker = json.loads(paths["marker"].read_text(encoding="utf-8"))
796
+ except FileNotFoundError:
797
+ return None
798
+ except (OSError, json.JSONDecodeError) as exc:
799
+ raise EchoesError(f"Invalid EchoesVault marker: {exc}") from exc
800
+ if not isinstance(marker, dict):
801
+ raise EchoesError("Invalid EchoesVault marker: root must be an object.")
802
+ return marker
803
+
804
+
805
+ def ensure_protocol_marker(paths: dict[str, Path]) -> bool:
806
+ marker = read_marker(paths)
807
+ if marker is not None:
808
+ version = marker.get("protocolVersion")
809
+ if version not in (None, PROTOCOL_VERSION):
810
+ raise EchoesError(
811
+ f"Unsupported EchoesVault protocol {version!r}; this runtime supports "
812
+ f"{PROTOCOL_VERSION}. Upgrade the agent adapter before writing."
813
+ )
814
+ if marker == VAULT_MARKER:
815
+ return False
816
+ atomic_write(
817
+ paths["marker"], json.dumps(VAULT_MARKER, ensure_ascii=False, indent=2) + "\n"
818
+ )
819
+ return True
820
+
821
+
822
+ def vault_is_initialized(paths: dict[str, Path]) -> bool:
823
+ return (
824
+ paths["marker"].is_file()
825
+ or paths["index"].is_file()
826
+ or (paths["pages"].is_dir() and read_state(paths)["initialized"])
827
+ )
828
+
829
+
830
+ def ensure_structure(paths: dict[str, Path], create_marker: bool = False) -> None:
831
+ for key in ("raw", "pages", "daily", "assets"):
832
+ paths[key].mkdir(parents=True, exist_ok=True)
833
+ ensure_ignore_rules(paths)
834
+ if create_marker:
835
+ ensure_protocol_marker(paths)
836
+
837
+
838
+ def lock_is_stale(path: Path) -> bool:
839
+ try:
840
+ return time.time() - path.stat().st_mtime > STALE_LOCK_SECONDS
841
+ except OSError:
842
+ return False
843
+
844
+
845
+ @contextmanager
846
+ def vault_lock(paths: dict[str, Path]) -> Iterator[None]:
847
+ path = paths["lock"]
848
+ path.parent.mkdir(parents=True, exist_ok=True)
849
+ token = f"{os.getpid()}-{secrets.token_hex(8)}"
850
+ deadline = time.monotonic() + LOCK_WAIT_SECONDS
851
+ while True:
852
+ try:
853
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
854
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
855
+ handle.write(token + "\n")
856
+ break
857
+ except FileExistsError:
858
+ if lock_is_stale(path):
859
+ try:
860
+ path.unlink()
861
+ except FileNotFoundError:
862
+ pass
863
+ continue
864
+ if time.monotonic() >= deadline:
865
+ raise EchoesError(
866
+ "Another EchoesVault operation is still running. Retry after it finishes."
867
+ )
868
+ time.sleep(0.05)
869
+ try:
870
+ yield
871
+ finally:
872
+ try:
873
+ if path.read_text(encoding="utf-8").strip() == token:
874
+ path.unlink()
875
+ except FileNotFoundError:
876
+ pass
877
+
878
+
879
+ def stable_name(value: str) -> str:
880
+ return unicodedata.normalize("NFC", value)
881
+
882
+
883
+ def stable_path_key(path: Path) -> tuple[str, str]:
884
+ normalized = stable_name(path.name)
885
+ return normalized.casefold(), normalized
886
+
887
+
888
+ def markdown_files(directory: Path) -> list[Path]:
889
+ if not directory.is_dir():
890
+ return []
891
+ return sorted(
892
+ (
893
+ item
894
+ for item in directory.iterdir()
895
+ if item.is_file() and not item.is_symlink() and item.suffix.casefold() == ".md"
896
+ ),
897
+ key=stable_path_key,
898
+ )
899
+
900
+
901
+ def daily_markdown_files(directory: Path) -> list[Path]:
902
+ if not directory.is_dir():
903
+ return []
904
+ files = [
905
+ item
906
+ for item in directory.rglob("*.md")
907
+ if item.is_file() and not item.is_symlink()
908
+ ]
909
+ return sorted(
910
+ files,
911
+ key=lambda item: stable_name(item.relative_to(directory).as_posix()),
912
+ )
913
+
914
+
915
+ def decode_scalar(value: str, key: str) -> str:
916
+ stripped = value.strip()
917
+ if not stripped:
918
+ raise EchoesError(f"YAML frontmatter key {key!r} must have an inline value.")
919
+ if stripped.startswith('"'):
920
+ try:
921
+ decoded = json.loads(stripped)
922
+ except json.JSONDecodeError as exc:
923
+ raise EchoesError(f"Invalid quoted YAML value for {key}: {exc}") from exc
924
+ if not isinstance(decoded, str):
925
+ raise EchoesError(f"YAML frontmatter key {key!r} must be a string.")
926
+ return decoded
927
+ if stripped.startswith("'") and stripped.endswith("'") and len(stripped) >= 2:
928
+ return stripped[1:-1].replace("''", "'")
929
+ return stripped
930
+
931
+
932
+ def normalize_summary(value: Any, slug: Optional[str] = None) -> str:
933
+ if not isinstance(value, str) or not value.strip():
934
+ raise EchoesError("Page summary must be a non-empty single-line string.")
935
+ candidate = value.strip()
936
+ match = INDEX_ENTRY_RE.match(candidate)
937
+ if match:
938
+ if slug is not None and match.group(1) != slug:
939
+ raise EchoesError(
940
+ f"Index link [[{match.group(1)}]] does not match page [[{slug}]]."
941
+ )
942
+ candidate = match.group(2).strip()
943
+ if "\n" in candidate or "\r" in candidate:
944
+ raise EchoesError("Page summary must fit on one line.")
945
+ summary = " ".join(candidate.split())
946
+ if not summary:
947
+ raise EchoesError("Page summary cannot be empty.")
948
+ if len(summary) > SUMMARY_MAX_LENGTH:
949
+ raise EchoesError(
950
+ f"Page summary exceeds {SUMMARY_MAX_LENGTH} characters ({len(summary)})."
951
+ )
952
+ if CONFLICT_MARKER_RE.search(summary):
953
+ raise EchoesError("Page summary contains a Git conflict marker.")
954
+ return summary
955
+
956
+
957
+ def parse_frontmatter(
958
+ content: Any, require_summary: bool = True
959
+ ) -> tuple[str, dict[str, str]]:
960
+ if not isinstance(content, str) or not content.strip():
961
+ raise EchoesError("Page content must be a non-empty string.")
962
+ normalized = content.strip() + "\n"
963
+ if CONFLICT_MARKER_RE.search(normalized):
964
+ raise EchoesError("Page contains unresolved Git conflict markers.")
965
+ lines = normalized.splitlines()
966
+ if not lines or lines[0] != "---":
967
+ raise EchoesError("Every page must begin with YAML frontmatter.")
968
+ try:
969
+ closing = lines.index("---", 1)
970
+ except ValueError as exc:
971
+ raise EchoesError("YAML frontmatter is missing its closing '---'.") from exc
972
+ if closing == 1:
973
+ raise EchoesError("YAML frontmatter cannot be empty.")
974
+ raw_values: dict[str, str] = {}
975
+ valued_keys: set[str] = set()
976
+ frontmatter_lines = lines[1:closing]
977
+ for position, line in enumerate(frontmatter_lines):
978
+ if not line.strip() or line.lstrip().startswith("#") or line[0].isspace():
979
+ continue
980
+ match = re.match(r"^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$", line)
981
+ if not match:
982
+ continue
983
+ key, inline = match.group(1), match.group(2)
984
+ if key in raw_values:
985
+ raise EchoesError(f"YAML frontmatter contains duplicate key: {key}")
986
+ raw_values[key] = inline
987
+ if inline.strip():
988
+ valued_keys.add(key)
989
+ continue
990
+ for following in frontmatter_lines[position + 1 :]:
991
+ if not following.strip() or following.lstrip().startswith("#"):
992
+ continue
993
+ if following[0].isspace():
994
+ valued_keys.add(key)
995
+ break
996
+ required = REQUIRED_FRONTMATTER if require_summary else REQUIRED_FRONTMATTER[:-1]
997
+ missing = [key for key in required if key not in valued_keys]
998
+ if missing:
999
+ raise EchoesError(f"YAML frontmatter is missing required keys: {', '.join(missing)}")
1000
+ metadata = {
1001
+ "type": decode_scalar(raw_values["type"], "type"),
1002
+ "status": decode_scalar(raw_values["status"], "status").casefold(),
1003
+ }
1004
+ if "summary" in valued_keys:
1005
+ metadata["summary"] = normalize_summary(
1006
+ decode_scalar(raw_values["summary"], "summary")
1007
+ )
1008
+ return normalized, metadata
1009
+
1010
+
1011
+ def validate_frontmatter(content: Any) -> str:
1012
+ normalized, _metadata = parse_frontmatter(content, require_summary=True)
1013
+ return normalized
1014
+
1015
+
1016
+ def inject_summary(content: str, summary: str) -> str:
1017
+ normalized, metadata = parse_frontmatter(content, require_summary=False)
1018
+ if "summary" in metadata:
1019
+ return normalized
1020
+ lines = normalized.splitlines()
1021
+ closing = lines.index("---", 1)
1022
+ lines.insert(closing, f"summary: {json.dumps(summary, ensure_ascii=False)}")
1023
+ migrated = "\n".join(lines).rstrip() + "\n"
1024
+ validate_frontmatter(migrated)
1025
+ return migrated
1026
+
1027
+
1028
+ def parse_index(content: str) -> list[tuple[str, str, str]]:
1029
+ entries: list[tuple[str, str, str]] = []
1030
+ for line in content.splitlines():
1031
+ match = INDEX_ENTRY_RE.match(line)
1032
+ if match:
1033
+ entries.append((match.group(1), match.group(2).strip(), line))
1034
+ return entries
1035
+
1036
+
1037
+ def legacy_index_descriptions(paths: dict[str, Path]) -> dict[str, str]:
1038
+ try:
1039
+ content = paths["index"].read_text(encoding="utf-8")
1040
+ except OSError:
1041
+ return {}
1042
+ descriptions: dict[str, str] = {}
1043
+ duplicates: set[str] = set()
1044
+ for slug, description, _line in parse_index(content):
1045
+ if slug in descriptions:
1046
+ duplicates.add(slug)
1047
+ descriptions[slug] = description
1048
+ for slug in duplicates:
1049
+ descriptions.pop(slug, None)
1050
+ return descriptions
1051
+
1052
+
1053
+ def page_source_map(
1054
+ paths: dict[str, Path], overrides: Optional[dict[Path, str]] = None
1055
+ ) -> dict[Path, str]:
1056
+ sources: dict[Path, str] = {}
1057
+ for page in markdown_files(paths["pages"]):
1058
+ try:
1059
+ sources[page] = page.read_text(encoding="utf-8")
1060
+ except (OSError, UnicodeError) as exc:
1061
+ raise EchoesError(f"Cannot read page {page.name}: {exc}") from exc
1062
+ if overrides:
1063
+ sources.update(overrides)
1064
+ return sources
1065
+
1066
+
1067
+ def validate_unique_page_names(paths: Iterable[Path]) -> None:
1068
+ seen: dict[str, str] = {}
1069
+ for path in paths:
1070
+ normalized = stable_name(path.stem)
1071
+ folded = normalized.casefold()
1072
+ if folded in seen:
1073
+ raise EchoesError(
1074
+ f"Page filenames collide across filesystems: {seen[folded]!r} and {normalized!r}."
1075
+ )
1076
+ seen[folded] = normalized
1077
+
1078
+
1079
+ def render_index_from_sources(sources: dict[Path, str]) -> str:
1080
+ validate_unique_page_names(sources)
1081
+ rows: list[tuple[tuple[str, str], str]] = []
1082
+ for page, content in sources.items():
1083
+ try:
1084
+ _normalized, metadata = parse_frontmatter(content, require_summary=True)
1085
+ except EchoesError as exc:
1086
+ raise EchoesError(f"Cannot generate index from {page.name}: {exc}") from exc
1087
+ slug = stable_name(page.stem)
1088
+ summary = metadata["summary"]
1089
+ if metadata["status"] == "deprecated" and not summary.casefold().startswith("deprecated"):
1090
+ summary = f"DEPRECATED — {summary}"
1091
+ rows.append((stable_path_key(page), f"- [[{slug}]]: {summary}"))
1092
+ rows.sort(key=lambda item: item[0])
1093
+ body = "\n".join(row for _key, row in rows)
1094
+ return DEFAULT_INDEX + ("\n" + body + "\n" if body else "")
1095
+
1096
+
1097
+ def migrate_legacy_summaries(paths: dict[str, Path]) -> list[str]:
1098
+ sources = page_source_map(paths)
1099
+ validate_unique_page_names(sources)
1100
+ legacy = legacy_index_descriptions(paths)
1101
+ migrations: list[tuple[Path, str]] = []
1102
+ errors: list[str] = []
1103
+ for page, content in sources.items():
1104
+ try:
1105
+ normalized, metadata = parse_frontmatter(content, require_summary=False)
1106
+ except EchoesError as exc:
1107
+ errors.append(f"{page.name}: {exc}")
1108
+ continue
1109
+ if "summary" in metadata:
1110
+ continue
1111
+ description = legacy.get(page.stem)
1112
+ if not description:
1113
+ errors.append(
1114
+ f"{page.name}: missing summary and no legacy index description is available"
1115
+ )
1116
+ continue
1117
+ try:
1118
+ summary = normalize_summary(description, page.stem)
1119
+ migrations.append((page, inject_summary(normalized, summary)))
1120
+ except EchoesError as exc:
1121
+ errors.append(f"{page.name}: {exc}")
1122
+ if errors:
1123
+ raise EchoesError("Index metadata errors: " + "; ".join(errors))
1124
+ for page, content in migrations:
1125
+ atomic_write(page, content)
1126
+ return [page.name for page, _content in migrations]
1127
+
1128
+
1129
+ def refresh_index(
1130
+ paths: dict[str, Path], allow_legacy_migration: bool = False
1131
+ ) -> dict[str, Any]:
1132
+ migrated = migrate_legacy_summaries(paths) if allow_legacy_migration else []
1133
+ generated = render_index_from_sources(page_source_map(paths))
1134
+ try:
1135
+ current = paths["index"].read_text(encoding="utf-8")
1136
+ except OSError:
1137
+ current = ""
1138
+ rebuilt = current != generated
1139
+ if rebuilt:
1140
+ atomic_write(paths["index"], generated)
1141
+ return {
1142
+ "rebuilt": rebuilt,
1143
+ "migratedSummaries": migrated,
1144
+ "pageCount": len(parse_index(generated)),
1145
+ "sha256": hashlib.sha256(generated.encode("utf-8")).hexdigest(),
1146
+ }
1147
+
1148
+
1149
+ def inspect_index(paths: dict[str, Path]) -> tuple[dict[str, Any], Optional[str]]:
1150
+ """Compare the index with its deterministic rendering without writing anything."""
1151
+ try:
1152
+ generated = render_index_from_sources(page_source_map(paths))
1153
+ except EchoesError as exc:
1154
+ return {
1155
+ "rebuilt": False,
1156
+ "needsRefresh": False,
1157
+ "migratedSummaries": [],
1158
+ "pageCount": len(markdown_files(paths["pages"])),
1159
+ "sha256": None,
1160
+ }, str(exc)
1161
+ try:
1162
+ current = paths["index"].read_text(encoding="utf-8")
1163
+ except OSError:
1164
+ current = ""
1165
+ needs_refresh = current != generated
1166
+ return {
1167
+ "rebuilt": False,
1168
+ "needsRefresh": needs_refresh,
1169
+ "migratedSummaries": [],
1170
+ "pageCount": len(parse_index(generated)),
1171
+ "sha256": hashlib.sha256(generated.encode("utf-8")).hexdigest(),
1172
+ }, None
1173
+
1174
+
1175
+ def legacy_vault_detected(paths: dict[str, Path]) -> bool:
1176
+ if paths["marker"].is_file():
1177
+ return False
1178
+ return (
1179
+ paths["index"].is_file()
1180
+ or bool(markdown_files(paths["pages"]))
1181
+ or bool(daily_markdown_files(paths["daily"]))
1182
+ )
1183
+
1184
+
1185
+ def require_initialized(paths: dict[str, Path]) -> dict[str, Any]:
1186
+ marker = read_marker(paths)
1187
+ if marker is None:
1188
+ if legacy_vault_detected(paths):
1189
+ raise EchoesError(
1190
+ "Legacy EchoesVault detected. Run the echoes-init workflow to migrate it."
1191
+ )
1192
+ raise EchoesError("EchoesVault is not initialized. Run the echoes-init workflow first.")
1193
+ version = marker.get("protocolVersion")
1194
+ if version != PROTOCOL_VERSION:
1195
+ raise EchoesError(
1196
+ f"Unsupported EchoesVault protocol {version!r}; this runtime supports "
1197
+ f"{PROTOCOL_VERSION}. Upgrade the agent adapter before writing."
1198
+ )
1199
+ if marker.get("schemaVersion") != SCHEMA_VERSION:
1200
+ raise EchoesError(
1201
+ f"Unsupported EchoesVault marker schema {marker.get('schemaVersion')!r}; "
1202
+ f"this engine supports schema {SCHEMA_VERSION}. Run init or migrate explicitly."
1203
+ )
1204
+ for key in ("generatedIndex", "dailyLayout", "runtime", "requiredFrontmatter"):
1205
+ if marker.get(key) != VAULT_MARKER[key]:
1206
+ raise EchoesError(
1207
+ f"Invalid EchoesVault marker field {key!r}. Run init or migrate explicitly."
1208
+ )
1209
+ state = read_state(paths)
1210
+ state["initialized"] = True
1211
+ return state
1212
+
1213
+
1214
+ def vault_inventory(vault: Path) -> dict[str, Any]:
1215
+ """Collect size and filesystem integrity without following symbolic links."""
1216
+ total_bytes = 0
1217
+ total_files = 0
1218
+ markdown_count = 0
1219
+ symlinks: list[str] = []
1220
+ unreadable: list[str] = []
1221
+ latest_mtime: Optional[float] = None
1222
+ if not vault.is_dir():
1223
+ return {
1224
+ "totalBytes": 0,
1225
+ "totalFiles": 0,
1226
+ "markdownFiles": 0,
1227
+ "symlinks": [],
1228
+ "unreadableFiles": [],
1229
+ "lastModified": None,
1230
+ }
1231
+ for root_name, directory_names, file_names in os.walk(vault, followlinks=False):
1232
+ root = Path(root_name)
1233
+ safe_directories: list[str] = []
1234
+ for directory_name in directory_names:
1235
+ directory = root / directory_name
1236
+ if directory.is_symlink():
1237
+ symlinks.append(str(directory.relative_to(vault)))
1238
+ else:
1239
+ safe_directories.append(directory_name)
1240
+ directory_names[:] = safe_directories
1241
+ for file_name in file_names:
1242
+ file_path = root / file_name
1243
+ relative = str(file_path.relative_to(vault))
1244
+ if file_path.is_symlink():
1245
+ symlinks.append(relative)
1246
+ continue
1247
+ try:
1248
+ stat = file_path.stat()
1249
+ except OSError:
1250
+ unreadable.append(relative)
1251
+ continue
1252
+ if not file_path.is_file():
1253
+ continue
1254
+ total_files += 1
1255
+ total_bytes += stat.st_size
1256
+ markdown_count += int(file_path.suffix.casefold() == ".md")
1257
+ latest_mtime = max(latest_mtime or stat.st_mtime, stat.st_mtime)
1258
+ return {
1259
+ "totalBytes": total_bytes,
1260
+ "totalFiles": total_files,
1261
+ "markdownFiles": markdown_count,
1262
+ "symlinks": sorted(symlinks),
1263
+ "unreadableFiles": sorted(unreadable),
1264
+ "lastModified": (
1265
+ datetime.fromtimestamp(latest_mtime).astimezone().isoformat(timespec="seconds")
1266
+ if latest_mtime is not None
1267
+ else None
1268
+ ),
1269
+ }
1270
+
1271
+
1272
+ def format_bytes(value: int) -> str:
1273
+ amount = float(max(0, value))
1274
+ units = ("B", "KB", "MB", "GB", "TB")
1275
+ unit = units[0]
1276
+ for unit in units:
1277
+ if amount < 1024 or unit == units[-1]:
1278
+ break
1279
+ amount /= 1024
1280
+ return f"{int(amount)} {unit}" if unit == "B" else f"{amount:.1f} {unit}"
1281
+
1282
+
1283
+ def state_file_health(path: Path) -> dict[str, Any]:
1284
+ if not path.exists():
1285
+ return {"exists": False, "valid": False, "error": "state file is missing"}
1286
+ if path.is_symlink():
1287
+ return {"exists": True, "valid": False, "error": "state file is a symbolic link"}
1288
+ try:
1289
+ raw = json.loads(path.read_text(encoding="utf-8"))
1290
+ except (OSError, json.JSONDecodeError) as exc:
1291
+ return {"exists": True, "valid": False, "error": str(exc)}
1292
+ if not isinstance(raw, dict):
1293
+ return {"exists": True, "valid": False, "error": "state root is not an object"}
1294
+ if raw.get("version") != STATE_VERSION:
1295
+ return {
1296
+ "exists": True,
1297
+ "valid": False,
1298
+ "error": f"unsupported state version: {raw.get('version')!r}",
1299
+ }
1300
+ if raw.get("protocolVersion") != PROTOCOL_VERSION:
1301
+ return {
1302
+ "exists": True,
1303
+ "valid": False,
1304
+ "error": f"state protocol mismatch: {raw.get('protocolVersion')!r}",
1305
+ }
1306
+ if raw.get("engineVersion") != ENGINE_VERSION:
1307
+ return {
1308
+ "exists": True,
1309
+ "valid": False,
1310
+ "error": f"state engine mismatch: {raw.get('engineVersion')!r}",
1311
+ }
1312
+ last_writer = raw.get("lastWriter")
1313
+ if not isinstance(last_writer, dict):
1314
+ return {
1315
+ "exists": True,
1316
+ "valid": False,
1317
+ "error": "state lastWriter must be an object",
1318
+ }
1319
+ return {"exists": True, "valid": True, "error": None}
1320
+
1321
+
1322
+ def regular_file_contains(path: Path, marker: str) -> bool:
1323
+ if not path.is_file() or path.is_symlink():
1324
+ return False
1325
+ try:
1326
+ return marker in path.read_text(encoding="utf-8")
1327
+ except (OSError, UnicodeError):
1328
+ return False
1329
+
1330
+
1331
+ def regular_file_contains_all(path: Path, markers: Iterable[str]) -> bool:
1332
+ if not path.is_file() or path.is_symlink():
1333
+ return False
1334
+ try:
1335
+ content = path.read_text(encoding="utf-8")
1336
+ except (OSError, UnicodeError):
1337
+ return False
1338
+ return all(marker in content for marker in markers)
1339
+
1340
+
1341
+ def format_version(value: Optional[tuple[int, int, int]]) -> Optional[str]:
1342
+ return ".".join(str(part) for part in value) if value is not None else None
1343
+
1344
+
1345
+ def configured_version(value: str) -> tuple[int, int, int]:
1346
+ major, minor, patch = value.split(".")
1347
+ return int(major), int(minor), int(patch)
1348
+
1349
+
1350
+ def project_runtime_health(paths: dict[str, Path]) -> dict[str, Any]:
1351
+ runtime = paths["runtime"]
1352
+ if not runtime.exists():
1353
+ return {
1354
+ "exists": False,
1355
+ "recognized": False,
1356
+ "engineVersion": None,
1357
+ "protocolVersion": None,
1358
+ "compatible": False,
1359
+ "current": False,
1360
+ "error": "project runtime is missing",
1361
+ }
1362
+ if runtime.is_symlink() or not runtime.is_file():
1363
+ return {
1364
+ "exists": True,
1365
+ "recognized": False,
1366
+ "engineVersion": None,
1367
+ "protocolVersion": None,
1368
+ "compatible": False,
1369
+ "current": False,
1370
+ "error": "project runtime is not a regular file",
1371
+ }
1372
+ try:
1373
+ content = runtime.read_text(encoding="utf-8")
1374
+ except (OSError, UnicodeError) as exc:
1375
+ return {
1376
+ "exists": True,
1377
+ "recognized": False,
1378
+ "engineVersion": None,
1379
+ "protocolVersion": None,
1380
+ "compatible": False,
1381
+ "current": False,
1382
+ "error": str(exc),
1383
+ }
1384
+ engine = engine_version_from_content(content)
1385
+ protocol = protocol_version_from_content(content)
1386
+ recognized = engine is not None and protocol is not None
1387
+ compatible = protocol == configured_version(PROTOCOL_VERSION)
1388
+ current = compatible and engine == configured_version(ENGINE_VERSION)
1389
+ return {
1390
+ "exists": True,
1391
+ "recognized": recognized,
1392
+ "engineVersion": format_version(engine),
1393
+ "protocolVersion": format_version(protocol),
1394
+ "compatible": compatible,
1395
+ "current": current,
1396
+ "error": None if recognized else "project runtime version metadata is missing",
1397
+ }
1398
+
1399
+
1400
+ def find_conflict_markers(vault: Path) -> list[str]:
1401
+ conflicts: list[str] = []
1402
+ if not vault.is_dir():
1403
+ return conflicts
1404
+ for path in sorted(vault.rglob("*.md")):
1405
+ if not path.is_file() or path.is_symlink():
1406
+ continue
1407
+ try:
1408
+ content = path.read_text(encoding="utf-8")
1409
+ except (OSError, UnicodeError):
1410
+ continue
1411
+ if CONFLICT_MARKER_RE.search(content):
1412
+ conflicts.append(path.relative_to(vault).as_posix())
1413
+ return conflicts
1414
+
1415
+
1416
+ def today_log_content(paths: dict[str, Path]) -> str:
1417
+ candidates: list[Path] = []
1418
+ for date_value in dict.fromkeys((today(), utc_today())):
1419
+ legacy = paths["daily"] / f"{date_value}.md"
1420
+ if legacy.is_file() and not legacy.is_symlink():
1421
+ candidates.append(legacy)
1422
+ directory = paths["daily"] / date_value
1423
+ if directory.is_dir():
1424
+ candidates.extend(daily_markdown_files(directory))
1425
+ contents: list[str] = []
1426
+ for path in candidates:
1427
+ try:
1428
+ contents.append(path.read_text(encoding="utf-8"))
1429
+ except (OSError, UnicodeError):
1430
+ continue
1431
+ return "\n".join(contents)
1432
+
1433
+
1434
+ def repository_relative_files(root: Path) -> list[Path]:
1435
+ if not root.is_dir() or root.is_symlink():
1436
+ return []
1437
+ return sorted(
1438
+ (
1439
+ path
1440
+ for path in root.rglob("*")
1441
+ if path.is_file() and not path.is_symlink()
1442
+ ),
1443
+ key=lambda path: stable_name(path.as_posix()),
1444
+ )
1445
+
1446
+
1447
+ def durable_git_candidates(paths: dict[str, Path]) -> list[str]:
1448
+ candidates = [
1449
+ paths["marker"],
1450
+ paths["vaultIgnore"],
1451
+ paths["protocol"],
1452
+ paths["runtimeIgnore"],
1453
+ paths["runtime"],
1454
+ paths["agentsGuide"],
1455
+ paths["claudeGuide"],
1456
+ paths["claudeSkill"],
1457
+ paths["openCodeSkill"],
1458
+ ]
1459
+ candidates.extend(paths["openCodeCommands"] / name for name in OPENCODE_COMMANDS)
1460
+ legacy_skill_root = paths["workspace"] / ".opencode" / "skills"
1461
+ candidates.extend(
1462
+ legacy_skill_root / name / "SKILL.md" for name in LEGACY_OPENCODE_SKILLS
1463
+ )
1464
+ for key in ("pages", "daily", "assets", "raw"):
1465
+ candidates.extend(repository_relative_files(paths[key]))
1466
+ relative = {
1467
+ path.relative_to(paths["workspace"]).as_posix()
1468
+ for path in candidates
1469
+ if path.is_file() and not path.is_symlink()
1470
+ }
1471
+ return sorted(relative)
1472
+
1473
+
1474
+ def parse_nul_paths(value: str) -> set[str]:
1475
+ return {item for item in value.split("\0") if item}
1476
+
1477
+
1478
+ def git_readiness(paths: dict[str, Path]) -> dict[str, Any]:
1479
+ workspace = paths["workspace"]
1480
+ try:
1481
+ inside = subprocess.run(
1482
+ ["git", "-C", str(workspace), "rev-parse", "--is-inside-work-tree"],
1483
+ check=True,
1484
+ capture_output=True,
1485
+ text=True,
1486
+ timeout=3,
1487
+ )
1488
+ except (FileNotFoundError, subprocess.SubprocessError):
1489
+ return {
1490
+ "available": False,
1491
+ "ready": True,
1492
+ "trackedLocalFiles": [],
1493
+ "untrackedDurableFiles": [],
1494
+ "ignoredDurableFiles": [],
1495
+ "runtimeIgnored": False,
1496
+ "openCodeCommandsIgnored": [],
1497
+ "openCodeSkillsIgnored": [],
1498
+ "suggestedCommands": [],
1499
+ }
1500
+ if inside.stdout.strip() != "true":
1501
+ return {
1502
+ "available": False,
1503
+ "ready": True,
1504
+ "trackedLocalFiles": [],
1505
+ "untrackedDurableFiles": [],
1506
+ "ignoredDurableFiles": [],
1507
+ "runtimeIgnored": False,
1508
+ "openCodeCommandsIgnored": [],
1509
+ "openCodeSkillsIgnored": [],
1510
+ "suggestedCommands": [],
1511
+ }
1512
+ try:
1513
+ tracked_result = subprocess.run(
1514
+ ["git", "-C", str(workspace), "ls-files", "-z"],
1515
+ check=True,
1516
+ capture_output=True,
1517
+ text=True,
1518
+ timeout=5,
1519
+ )
1520
+ untracked_result = subprocess.run(
1521
+ [
1522
+ "git",
1523
+ "-C",
1524
+ str(workspace),
1525
+ "ls-files",
1526
+ "--others",
1527
+ "--exclude-standard",
1528
+ "-z",
1529
+ ],
1530
+ check=True,
1531
+ capture_output=True,
1532
+ text=True,
1533
+ timeout=5,
1534
+ )
1535
+ except (FileNotFoundError, subprocess.SubprocessError):
1536
+ return {
1537
+ "available": False,
1538
+ "ready": True,
1539
+ "trackedLocalFiles": [],
1540
+ "untrackedDurableFiles": [],
1541
+ "ignoredDurableFiles": [],
1542
+ "runtimeIgnored": False,
1543
+ "openCodeCommandsIgnored": [],
1544
+ "openCodeSkillsIgnored": [],
1545
+ "suggestedCommands": [],
1546
+ }
1547
+ tracked = parse_nul_paths(tracked_result.stdout)
1548
+ untracked = parse_nul_paths(untracked_result.stdout)
1549
+ durable = durable_git_candidates(paths)
1550
+ ignored: set[str] = set()
1551
+ if durable:
1552
+ try:
1553
+ ignored_result = subprocess.run(
1554
+ [
1555
+ "git",
1556
+ "-C",
1557
+ str(workspace),
1558
+ "check-ignore",
1559
+ "--no-index",
1560
+ "--stdin",
1561
+ "-z",
1562
+ ],
1563
+ input="\0".join(durable) + "\0",
1564
+ check=False,
1565
+ capture_output=True,
1566
+ text=True,
1567
+ timeout=5,
1568
+ )
1569
+ ignored = parse_nul_paths(ignored_result.stdout)
1570
+ except (FileNotFoundError, subprocess.SubprocessError):
1571
+ ignored = set()
1572
+ local_only = {
1573
+ paths["index"].relative_to(workspace).as_posix(),
1574
+ paths["state"].relative_to(workspace).as_posix(),
1575
+ paths["lock"].relative_to(workspace).as_posix(),
1576
+ paths["openCodeState"].relative_to(workspace).as_posix(),
1577
+ paths["legacyState"].relative_to(workspace).as_posix(),
1578
+ }
1579
+ tracked_local = sorted(local_only & tracked)
1580
+ untracked_durable = sorted(set(durable) & untracked)
1581
+ ignored_durable = sorted(set(durable) & ignored)
1582
+ runtime_relative = paths["runtime"].relative_to(workspace).as_posix()
1583
+ ignored_commands = [
1584
+ path for path in ignored_durable if path.startswith(".opencode/commands/")
1585
+ ]
1586
+ ignored_skills = [
1587
+ path for path in ignored_durable if path.startswith(".opencode/skills/")
1588
+ ]
1589
+ suggestions: list[str] = []
1590
+ if tracked_local:
1591
+ suggestions.append(
1592
+ "git rm --cached -- "
1593
+ + " ".join(shlex.quote(path) for path in tracked_local)
1594
+ )
1595
+ if ignored_durable:
1596
+ suggestions.append(
1597
+ "git add -f -- "
1598
+ + " ".join(shlex.quote(path) for path in ignored_durable)
1599
+ )
1600
+ addable = [path for path in untracked_durable if path not in ignored]
1601
+ if addable:
1602
+ suggestions.append(
1603
+ "git add -- " + " ".join(shlex.quote(path) for path in addable)
1604
+ )
1605
+ ready = not tracked_local and not untracked_durable and not ignored_durable
1606
+ return {
1607
+ "available": True,
1608
+ "ready": ready,
1609
+ "trackedLocalFiles": tracked_local,
1610
+ "untrackedDurableFiles": untracked_durable,
1611
+ "ignoredDurableFiles": ignored_durable,
1612
+ "runtimeIgnored": runtime_relative in ignored,
1613
+ "openCodeCommandsIgnored": ignored_commands,
1614
+ "openCodeSkillsIgnored": ignored_skills,
1615
+ "suggestedCommands": suggestions,
1616
+ }
1617
+
1618
+
1619
+ def marker_health(paths: dict[str, Path]) -> dict[str, Any]:
1620
+ try:
1621
+ marker = read_marker(paths)
1622
+ except EchoesError as exc:
1623
+ return {"exists": True, "valid": False, "value": None, "error": str(exc)}
1624
+ if marker is None:
1625
+ return {
1626
+ "exists": False,
1627
+ "valid": False,
1628
+ "value": None,
1629
+ "error": "initialization marker is missing",
1630
+ }
1631
+ if marker.get("protocolVersion") != PROTOCOL_VERSION:
1632
+ return {
1633
+ "exists": True,
1634
+ "valid": False,
1635
+ "value": marker,
1636
+ "error": f"unsupported marker protocol: {marker.get('protocolVersion')!r}",
1637
+ }
1638
+ if marker.get("schemaVersion") != SCHEMA_VERSION:
1639
+ return {
1640
+ "exists": True,
1641
+ "valid": False,
1642
+ "value": marker,
1643
+ "error": f"unsupported marker schema: {marker.get('schemaVersion')!r}",
1644
+ }
1645
+ for key in ("generatedIndex", "dailyLayout", "runtime", "requiredFrontmatter"):
1646
+ if marker.get(key) != VAULT_MARKER[key]:
1647
+ return {
1648
+ "exists": True,
1649
+ "valid": False,
1650
+ "value": marker,
1651
+ "error": f"invalid marker field: {key}",
1652
+ }
1653
+ return {"exists": True, "valid": True, "value": marker, "error": None}
1654
+
1655
+
1656
+ def collect_health(
1657
+ paths: dict[str, Path], index_error: Optional[str] = None
1658
+ ) -> dict[str, Any]:
1659
+ index_inspection, detected_index_error = inspect_index(paths)
1660
+ if index_error is None:
1661
+ index_error = detected_index_error
1662
+ pages = markdown_files(paths["pages"])
1663
+ daily = daily_markdown_files(paths["daily"])
1664
+ deprecated: list[str] = []
1665
+ invalid_frontmatter: list[str] = []
1666
+ page_slugs = {stable_name(page.stem) for page in pages}
1667
+ for page in pages:
1668
+ try:
1669
+ content = page.read_text(encoding="utf-8")
1670
+ _normalized, metadata = parse_frontmatter(content, require_summary=True)
1671
+ if metadata["status"] == "deprecated":
1672
+ deprecated.append(page.stem)
1673
+ except (EchoesError, OSError, UnicodeError):
1674
+ invalid_frontmatter.append(page.stem)
1675
+ try:
1676
+ index_content = paths["index"].read_text(encoding="utf-8")
1677
+ except OSError:
1678
+ index_content = ""
1679
+ entries = parse_index(index_content)
1680
+ indexed_slugs = [slug for slug, _description, _line in entries]
1681
+ seen: set[str] = set()
1682
+ duplicates: list[str] = []
1683
+ for slug in indexed_slugs:
1684
+ if slug in seen and slug not in duplicates:
1685
+ duplicates.append(slug)
1686
+ seen.add(slug)
1687
+ runtime_health = project_runtime_health(paths)
1688
+ marker_status = marker_health(paths)
1689
+ adapter_conflicts = adapter_configuration_conflicts(paths)
1690
+ git_status = git_readiness(paths)
1691
+ required_paths = {
1692
+ "index.md": paths["index"].is_file() and not paths["index"].is_symlink(),
1693
+ MARKER_FILENAME: marker_status["valid"],
1694
+ PROTOCOL_FILENAME: regular_file_contains(
1695
+ paths["protocol"], f"Generated by EchoesVault protocol {PROTOCOL_VERSION}"
1696
+ ),
1697
+ "pages/": paths["pages"].is_dir() and not paths["pages"].is_symlink(),
1698
+ "daily/": paths["daily"].is_dir() and not paths["daily"].is_symlink(),
1699
+ "assets/": paths["assets"].is_dir() and not paths["assets"].is_symlink(),
1700
+ "raw/": paths["raw"].is_dir() and not paths["raw"].is_symlink(),
1701
+ f"{RUNTIME_DIRNAME}/{RUNTIME_FILENAME}": runtime_health["compatible"],
1702
+ "AGENTS.md": regular_file_contains_all(
1703
+ paths["agentsGuide"],
1704
+ (AGENT_GUIDE_START, f"Managed adapter version: {MANAGED_ADAPTER_VERSION}"),
1705
+ ),
1706
+ "CLAUDE.md": regular_file_contains_all(
1707
+ paths["claudeGuide"],
1708
+ (AGENT_GUIDE_START, f"Managed adapter version: {MANAGED_ADAPTER_VERSION}"),
1709
+ ),
1710
+ ".claude/skills/echoes-vault/SKILL.md": regular_file_contains_all(
1711
+ paths["claudeSkill"],
1712
+ (
1713
+ f"Generated by EchoesVault protocol {PROTOCOL_VERSION}",
1714
+ f"Managed adapter version: {MANAGED_ADAPTER_VERSION}",
1715
+ ),
1716
+ ),
1717
+ ".opencode/skills/echoes-vault/SKILL.md": regular_file_contains_all(
1718
+ paths["openCodeSkill"],
1719
+ (
1720
+ f"Generated by EchoesVault protocol {PROTOCOL_VERSION}",
1721
+ f"Managed adapter version: {MANAGED_ADAPTER_VERSION}",
1722
+ ),
1723
+ ),
1724
+ **{
1725
+ f".opencode/commands/{filename}": (
1726
+ regular_file_contains_all(
1727
+ paths["openCodeCommands"] / filename,
1728
+ (
1729
+ f"Generated by EchoesVault protocol {PROTOCOL_VERSION}",
1730
+ f"Managed adapter version: {MANAGED_ADAPTER_VERSION}",
1731
+ ),
1732
+ )
1733
+ )
1734
+ for filename in OPENCODE_COMMANDS
1735
+ },
1736
+ }
1737
+ inventory = vault_inventory(paths["vault"])
1738
+ state_health = state_file_health(paths["state"])
1739
+ git_issues = [
1740
+ *(f"tracked local file: {item}" for item in git_status["trackedLocalFiles"]),
1741
+ *(f"untracked durable file: {item}" for item in git_status["untrackedDurableFiles"]),
1742
+ *(f"ignored durable file: {item}" for item in git_status["ignoredDurableFiles"]),
1743
+ ]
1744
+ problems = {
1745
+ "missingStructure": sorted(name for name, present in required_paths.items() if not present),
1746
+ "invalidFrontmatter": sorted(set(invalid_frontmatter)),
1747
+ "duplicateIndexEntries": duplicates,
1748
+ "emptyDescriptions": [slug for slug, description, _line in entries if not description],
1749
+ "orphanPages": sorted(page_slugs - set(indexed_slugs)),
1750
+ "missingPages": sorted(set(indexed_slugs) - page_slugs),
1751
+ "conflictMarkers": find_conflict_markers(paths["vault"]),
1752
+ "indexBuildErrors": [index_error] if index_error else [],
1753
+ "indexOutOfDate": ["index.md"] if index_inspection["needsRefresh"] else [],
1754
+ "symbolicLinks": inventory["symlinks"],
1755
+ "unreadableFiles": inventory["unreadableFiles"],
1756
+ "adapterConfiguration": adapter_conflicts,
1757
+ "gitReadiness": git_issues,
1758
+ "outdatedRuntime": (
1759
+ [
1760
+ f"project engine {runtime_health['engineVersion']} is older than "
1761
+ f"available engine {ENGINE_VERSION}"
1762
+ ]
1763
+ if runtime_health["compatible"] and not runtime_health["current"]
1764
+ else []
1765
+ ),
1766
+ }
1767
+ issue_count = sum(len(items) for items in problems.values())
1768
+ return {
1769
+ "totalPages": len(pages),
1770
+ "totalDailyLogs": len(daily),
1771
+ "deprecatedPages": len(deprecated),
1772
+ "deprecatedSlugs": sorted(deprecated),
1773
+ "indexTopics": len(entries),
1774
+ "generatedIndex": True,
1775
+ "protocolVersion": PROTOCOL_VERSION,
1776
+ "engineVersion": ENGINE_VERSION,
1777
+ "managedAdapterVersion": MANAGED_ADAPTER_VERSION,
1778
+ "codexAdapterVersion": MANAGED_ADAPTER_VERSION,
1779
+ "initialized": marker_status["valid"],
1780
+ "legacyVaultDetected": legacy_vault_detected(paths),
1781
+ **problems,
1782
+ "todayEntries": len(ENTRY_HEADER_RE.findall(today_log_content(paths))),
1783
+ "scaleAlert": len(pages) > 200,
1784
+ "integrity": "healthy" if issue_count == 0 and state_health["valid"] else "attention",
1785
+ "issueCount": issue_count + int(not state_health["valid"]),
1786
+ "stateFile": state_health,
1787
+ "marker": marker_status,
1788
+ "projectRuntime": runtime_health,
1789
+ "git": git_status,
1790
+ "storage": inventory,
1791
+ }
1792
+
1793
+
1794
+ def format_status_card(status: dict[str, Any]) -> str:
1795
+ state = status["state"]
1796
+ health = status["health"]
1797
+ initialized = bool(health["initialized"])
1798
+ legacy_detected = bool(health["legacyVaultDetected"])
1799
+ session = state.get("session") if isinstance(state.get("session"), dict) else {}
1800
+ if legacy_detected and not initialized:
1801
+ badge = "△ Legacy vault detected"
1802
+ elif not initialized:
1803
+ badge = "○ Not initialized"
1804
+ elif health["integrity"] == "healthy":
1805
+ badge = "✓ Healthy"
1806
+ else:
1807
+ badge = f"△ Needs attention ({health['issueCount']})"
1808
+ session_label = "not started"
1809
+ if session.get("started"):
1810
+ session_label = "saved" if session.get("saved") else "active"
1811
+ storage = health["storage"]
1812
+ lines = [
1813
+ f"### EchoesVault · {badge}",
1814
+ "",
1815
+ "| Storage | Knowledge | Session |",
1816
+ "|---|---|---|",
1817
+ (
1818
+ f"| {format_bytes(int(storage['totalBytes']))} · {storage['totalFiles']} files "
1819
+ f"| {health['totalPages']} pages · {health['totalDailyLogs']} logs "
1820
+ f"| {session_label} |"
1821
+ ),
1822
+ ]
1823
+ if initialized and health["integrity"] == "healthy":
1824
+ lines.extend(
1825
+ [
1826
+ "",
1827
+ f"Protocol: {health['protocolVersion']} · engine: {health['engineVersion']} · adapters ready.",
1828
+ "",
1829
+ "Integrity: generated index, structure, metadata, and local paths are consistent.",
1830
+ ]
1831
+ )
1832
+ elif initialized:
1833
+ details = []
1834
+ labels = (
1835
+ ("missingStructure", "missing structure"),
1836
+ ("invalidFrontmatter", "invalid frontmatter"),
1837
+ ("duplicateIndexEntries", "duplicate index entries"),
1838
+ ("emptyDescriptions", "empty descriptions"),
1839
+ ("orphanPages", "orphan pages"),
1840
+ ("missingPages", "missing pages"),
1841
+ ("conflictMarkers", "Git conflict markers"),
1842
+ ("indexBuildErrors", "index build errors"),
1843
+ ("indexOutOfDate", "stale generated index"),
1844
+ ("symbolicLinks", "symbolic links"),
1845
+ ("unreadableFiles", "unreadable files"),
1846
+ ("adapterConfiguration", "adapter conflicts"),
1847
+ ("gitReadiness", "Git readiness issues"),
1848
+ ("outdatedRuntime", "outdated project runtime"),
1849
+ )
1850
+ for key, label in labels:
1851
+ if health[key]:
1852
+ details.append(f"{label}: {len(health[key])}")
1853
+ if not health["stateFile"]["valid"]:
1854
+ details.append("invalid runtime state")
1855
+ lines.extend(
1856
+ [
1857
+ "",
1858
+ f"Protocol: {health['protocolVersion']} · engine: {health['engineVersion']}.",
1859
+ "",
1860
+ "Integrity: " + "; ".join(details) + ".",
1861
+ ]
1862
+ )
1863
+ elif legacy_detected:
1864
+ lines.extend(
1865
+ [
1866
+ "",
1867
+ "Legacy vault detected. Choose **Initialize or restore EchoesVault** to migrate it.",
1868
+ ]
1869
+ )
1870
+ else:
1871
+ lines.extend(["", "Choose **Initialize or restore EchoesVault** to create local Markdown memory."])
1872
+ if health["scaleAlert"]:
1873
+ lines.extend(["", "> Scale alert: more than 200 pages; prefer targeted search."])
1874
+ lines.extend(
1875
+ [
1876
+ "",
1877
+ "Actions: **Initialize or restore** · **Show vault status** · **Save this session**",
1878
+ ]
1879
+ )
1880
+ return "\n".join(lines) + "\n"
1881
+
1882
+
1883
+ def write_state(
1884
+ paths: dict[str, Path],
1885
+ state: dict[str, Any],
1886
+ args: Optional[argparse.Namespace] = None,
1887
+ writer_agent: Optional[str] = None,
1888
+ ) -> None:
1889
+ state["version"] = STATE_VERSION
1890
+ state["protocolVersion"] = PROTOCOL_VERSION
1891
+ state["engineVersion"] = ENGINE_VERSION
1892
+ state.pop("pluginVersion", None)
1893
+ state.pop("runtimeVersion", None)
1894
+ requested_agent = writer_agent or (getattr(args, "agent", None) if args else None)
1895
+ adapter_version = getattr(args, "adapter_version", None) if args else None
1896
+ if requested_agent:
1897
+ state["lastWriter"] = {
1898
+ "agent": normalize_agent_name(requested_agent),
1899
+ "adapterVersion": (
1900
+ adapter_version
1901
+ if isinstance(adapter_version, str) and adapter_version.strip()
1902
+ else None
1903
+ ),
1904
+ }
1905
+ elif not isinstance(state.get("lastWriter"), dict):
1906
+ state["lastWriter"] = {"agent": None, "adapterVersion": None}
1907
+ health = collect_health(paths)
1908
+ state["stats"] = {
1909
+ "totalPages": health["totalPages"],
1910
+ "totalDailyLogs": health["totalDailyLogs"],
1911
+ "deprecatedPages": health["deprecatedPages"],
1912
+ }
1913
+ atomic_write(paths["state"], json.dumps(state, ensure_ascii=False, indent=2) + "\n")
1914
+
1915
+
1916
+ def normalize_filename(value: Any) -> str:
1917
+ if not isinstance(value, str) or not value.strip():
1918
+ raise EchoesError("Page filename must be a non-empty string.")
1919
+ name = stable_name(value.strip())
1920
+ if name.endswith(".md"):
1921
+ name = name[:-3]
1922
+ if not name or name in {".", ".."} or "/" in name or "\\" in name or ".." in name:
1923
+ raise EchoesError(f"Unsafe page filename: {value!r}")
1924
+ if name.startswith("."):
1925
+ raise EchoesError("Hidden page filenames are not allowed.")
1926
+ return f"{name}.md"
1927
+
1928
+
1929
+ def sha256_text(content: str) -> str:
1930
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
1931
+
1932
+
1933
+ def load_payload(path_value: str) -> dict[str, Any]:
1934
+ try:
1935
+ raw = (
1936
+ sys.stdin.read()
1937
+ if path_value == "-"
1938
+ else Path(path_value).expanduser().read_text(encoding="utf-8")
1939
+ )
1940
+ payload = json.loads(raw)
1941
+ except (OSError, json.JSONDecodeError) as exc:
1942
+ raise EchoesError(f"Cannot read JSON payload: {exc}") from exc
1943
+ if not isinstance(payload, dict):
1944
+ raise EchoesError("JSON payload must be an object.")
1945
+ return payload
1946
+
1947
+
1948
+ def prepare_page(paths: dict[str, Path], item: dict[str, Any]) -> tuple[Path, str, bool]:
1949
+ filename = normalize_filename(item.get("filename"))
1950
+ page_path = paths["pages"] / filename
1951
+ if page_path.is_symlink():
1952
+ raise EchoesError(f"Refusing to read or replace a symbolic-link page: {filename}")
1953
+ normalized, metadata = parse_frontmatter(item.get("content"), require_summary=False)
1954
+ legacy_description = item.get("indexDescription")
1955
+ if "summary" not in metadata:
1956
+ summary = normalize_summary(legacy_description, page_path.stem)
1957
+ normalized = inject_summary(normalized, summary)
1958
+ _normalized, metadata = parse_frontmatter(normalized, require_summary=True)
1959
+ elif legacy_description is not None:
1960
+ legacy_summary = normalize_summary(legacy_description, page_path.stem)
1961
+ if legacy_summary != metadata["summary"]:
1962
+ raise EchoesError(
1963
+ f"indexDescription conflicts with frontmatter summary for {filename}."
1964
+ )
1965
+ content = validate_frontmatter(normalized)
1966
+ existing = page_path.exists()
1967
+ if existing:
1968
+ expected = item.get("expectedSha256")
1969
+ if not isinstance(expected, str) or not expected:
1970
+ raise EchoesError(
1971
+ f"Updating {filename} requires expectedSha256 from the content read before writing."
1972
+ )
1973
+ current = page_path.read_text(encoding="utf-8")
1974
+ actual = sha256_text(current)
1975
+ if expected != actual:
1976
+ raise EchoesError(
1977
+ f"Concurrent change detected for {filename}: expected {expected}, found {actual}."
1978
+ )
1979
+ return page_path, content, existing
1980
+
1981
+
1982
+ def normalize_agent_name(value: Any) -> Optional[str]:
1983
+ if value is None:
1984
+ return None
1985
+ if not isinstance(value, str) or not value.strip():
1986
+ raise EchoesError("Payload agent must be a non-empty string when provided.")
1987
+ normalized = re.sub(r"[^a-z0-9._-]+", "-", value.strip().casefold()).strip("-.")
1988
+ if not normalized:
1989
+ raise EchoesError("Payload agent must contain at least one letter or number.")
1990
+ return normalized[:40]
1991
+
1992
+
1993
+ def new_daily_log(
1994
+ paths: dict[str, Path], kind: str, content: str, agent: Optional[str] = None
1995
+ ) -> Path:
1996
+ moment = utc_now()
1997
+ directory = paths["daily"] / moment.date().isoformat()
1998
+ agent_part = f"-{agent}" if agent else ""
1999
+ name = (
2000
+ f"{moment.strftime('%Y%m%dT%H%M%S%f')}Z-{kind}{agent_part}-"
2001
+ f"{secrets.token_hex(4)}.md"
2002
+ )
2003
+ path = directory / name
2004
+ atomic_write(path, content)
2005
+ return path
2006
+
2007
+
2008
+ def cmd_init(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2009
+ existed = marker_health(paths)["valid"]
2010
+ ensure_structure(paths, create_marker=True)
2011
+ state = read_state(paths)
2012
+ state["initialized"] = True
2013
+ adapters = configure_agent_adapters(paths)
2014
+ index = refresh_index(paths, allow_legacy_migration=True)
2015
+ write_state(paths, state, args)
2016
+ return {
2017
+ "ok": True,
2018
+ "operation": args.command,
2019
+ "created": not existed,
2020
+ "vault": str(paths["vault"]),
2021
+ "index": str(paths["index"]),
2022
+ "indexRefresh": index,
2023
+ "agentAdapters": adapters,
2024
+ "state": state,
2025
+ }
2026
+
2027
+
2028
+ def cmd_protocol(paths: dict[str, Path], _args: argparse.Namespace) -> dict[str, Any]:
2029
+ marker_status = marker_health(paths)
2030
+ marker = marker_status["value"] or {}
2031
+ return {
2032
+ "ok": True,
2033
+ "managedAdapterVersion": MANAGED_ADAPTER_VERSION,
2034
+ "codexAdapterVersion": MANAGED_ADAPTER_VERSION,
2035
+ "engineVersion": ENGINE_VERSION,
2036
+ "supportedProtocolVersion": PROTOCOL_VERSION,
2037
+ "vaultProtocolVersion": marker.get("protocolVersion"),
2038
+ "marker": marker_status,
2039
+ "projectRuntime": project_runtime_health(paths),
2040
+ "runtime": str(paths["runtime"]),
2041
+ "protocol": str(paths["protocol"]),
2042
+ "commands": [
2043
+ "init",
2044
+ "migrate",
2045
+ "upgrade",
2046
+ "protocol",
2047
+ "configure-agents",
2048
+ "inspect",
2049
+ "hydrate",
2050
+ "start",
2051
+ "status",
2052
+ "search",
2053
+ "append",
2054
+ "upsert",
2055
+ "end",
2056
+ "hash",
2057
+ "rebuild-index",
2058
+ ],
2059
+ }
2060
+
2061
+
2062
+ def cmd_configure_agents(
2063
+ paths: dict[str, Path], args: argparse.Namespace
2064
+ ) -> dict[str, Any]:
2065
+ state = require_initialized(paths)
2066
+ ensure_structure(paths, create_marker=True)
2067
+ state["initialized"] = True
2068
+ adapters = configure_agent_adapters(paths)
2069
+ refresh_index(paths)
2070
+ write_state(paths, state, args)
2071
+ return {"ok": True, **adapters}
2072
+
2073
+
2074
+ def cmd_hydrate(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2075
+ state = require_initialized(paths)
2076
+ index = refresh_index(paths)
2077
+ write_state(paths, state, args)
2078
+ return {
2079
+ "ok": True,
2080
+ "index": str(paths["index"]),
2081
+ "indexRefresh": index,
2082
+ "state": state,
2083
+ }
2084
+
2085
+
2086
+ def cmd_start(paths: dict[str, Path], args: argparse.Namespace) -> str:
2087
+ state = require_initialized(paths)
2088
+ refresh_index(paths)
2089
+ recent_count = max(0, min(args.recent, 10))
2090
+ selected = list(reversed(daily_markdown_files(paths["daily"])))[:recent_count]
2091
+ index_content = paths["index"].read_text(encoding="utf-8")
2092
+ sections = ["# EchoesVault session context", "", "## Index", "", index_content.rstrip()]
2093
+ sections.extend(["", f"## Recent session entries ({len(selected)})"])
2094
+ if selected:
2095
+ for daily_file in selected:
2096
+ relative = daily_file.relative_to(paths["daily"]).as_posix()
2097
+ sections.extend(
2098
+ ["", f"### {relative}", "", daily_file.read_text(encoding="utf-8").rstrip()]
2099
+ )
2100
+ else:
2101
+ sections.extend(["", "No session entries found."])
2102
+ health = collect_health(paths)
2103
+ if health["scaleAlert"]:
2104
+ sections.extend(
2105
+ [
2106
+ "",
2107
+ "> [!warning] SCALE ALERT",
2108
+ "> The vault exceeds 200 pages. Prefer targeted search over loading every page.",
2109
+ ]
2110
+ )
2111
+ state["session"]["started"] = True
2112
+ state["session"]["saved"] = False
2113
+ state["session"]["lastStart"] = timestamp()
2114
+ write_state(paths, state, args)
2115
+ return "\n".join(sections).rstrip() + "\n"
2116
+
2117
+
2118
+ def cmd_inspect(paths: dict[str, Path], args: argparse.Namespace) -> Any:
2119
+ state = read_state(paths)
2120
+ index_refresh, index_error = inspect_index(paths)
2121
+ health = collect_health(paths, index_error=index_error)
2122
+ state["stats"] = {
2123
+ "totalPages": health["totalPages"],
2124
+ "totalDailyLogs": health["totalDailyLogs"],
2125
+ "deprecatedPages": health["deprecatedPages"],
2126
+ }
2127
+ status = {
2128
+ "ok": True,
2129
+ "workspace": str(paths["workspace"]),
2130
+ "vault": str(paths["vault"]),
2131
+ "state": state,
2132
+ "indexRefresh": index_refresh,
2133
+ "health": health,
2134
+ }
2135
+ return format_status_card(status) if args.format == "card" else status
2136
+
2137
+
2138
+ def cmd_status(paths: dict[str, Path], args: argparse.Namespace) -> Any:
2139
+ return cmd_inspect(paths, args)
2140
+
2141
+
2142
+ def cmd_search(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2143
+ require_initialized(paths)
2144
+ query = args.query.strip()
2145
+ if not query:
2146
+ raise EchoesError("Search query cannot be empty.")
2147
+ folded = query.casefold()
2148
+ results = []
2149
+ limit = max(1, min(args.limit, 500))
2150
+ for page in markdown_files(paths["pages"]):
2151
+ for line_number, line in enumerate(page.read_text(encoding="utf-8").splitlines(), 1):
2152
+ if folded in line.casefold():
2153
+ results.append(
2154
+ {
2155
+ "file": str(page.relative_to(paths["workspace"])),
2156
+ "line": line_number,
2157
+ "text": line.strip()[:300],
2158
+ }
2159
+ )
2160
+ if len(results) >= limit:
2161
+ return {"ok": True, "query": query, "truncated": True, "results": results}
2162
+ return {"ok": True, "query": query, "truncated": False, "results": results}
2163
+
2164
+
2165
+ def cmd_append(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2166
+ state = require_initialized(paths)
2167
+ payload = load_payload(args.payload)
2168
+ entry = payload.get("entry")
2169
+ if not isinstance(entry, str) or not entry.strip():
2170
+ raise EchoesError("Append payload requires a non-empty string field named 'entry'.")
2171
+ agent = normalize_agent_name(payload.get("agent") or args.agent)
2172
+ agent_line = f"\nAgent: `{agent}`\n" if agent else ""
2173
+ block = f"### Scratchpad — {timestamp()}\n{agent_line}\n{entry.strip()}\n"
2174
+ daily_file = new_daily_log(paths, "scratchpad", block, agent)
2175
+ write_state(paths, state, args, writer_agent=agent)
2176
+ return {
2177
+ "ok": True,
2178
+ "dailyLog": str(daily_file),
2179
+ "kind": "scratchpad",
2180
+ "agent": agent,
2181
+ }
2182
+
2183
+
2184
+ def cmd_upsert(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2185
+ state = require_initialized(paths)
2186
+ payload = load_payload(args.payload)
2187
+ page_path, content, existed = prepare_page(paths, payload)
2188
+ generated = render_index_from_sources(page_source_map(paths, {page_path: content}))
2189
+ atomic_write(page_path, content)
2190
+ try:
2191
+ current_index = paths["index"].read_text(encoding="utf-8")
2192
+ except OSError:
2193
+ current_index = ""
2194
+ index_changed = current_index != generated
2195
+ if index_changed:
2196
+ atomic_write(paths["index"], generated)
2197
+ write_state(paths, state, args)
2198
+ return {
2199
+ "ok": True,
2200
+ "action": "updated" if existed else "created",
2201
+ "page": str(page_path),
2202
+ "sha256": sha256_text(content),
2203
+ "indexChanged": index_changed,
2204
+ }
2205
+
2206
+
2207
+ def cmd_end(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2208
+ if not args.confirm_explicit_user_end:
2209
+ raise EchoesError(
2210
+ "Final memory save requires --confirm-explicit-user-end from the echoes-end workflow."
2211
+ )
2212
+ state = require_initialized(paths)
2213
+ payload = load_payload(args.payload)
2214
+ summary = payload.get("dailySummary")
2215
+ if not isinstance(summary, str) or not summary.strip():
2216
+ raise EchoesError("End payload requires a non-empty dailySummary.")
2217
+ pages = payload.get("pages", [])
2218
+ if not isinstance(pages, list) or not all(isinstance(item, dict) for item in pages):
2219
+ raise EchoesError("End payload pages must be an array of objects.")
2220
+ if payload.get("indexUpdates") not in (None, []):
2221
+ raise EchoesError(
2222
+ "indexUpdates are no longer accepted because index.md is generated from page summaries."
2223
+ )
2224
+ prepared: list[tuple[Path, str]] = []
2225
+ overrides: dict[Path, str] = {}
2226
+ for item in pages:
2227
+ page_path, content, _existed = prepare_page(paths, item)
2228
+ if page_path in overrides:
2229
+ raise EchoesError(f"End payload contains duplicate page: {page_path.name}")
2230
+ overrides[page_path] = content
2231
+ prepared.append((page_path, content))
2232
+ generated = render_index_from_sources(page_source_map(paths, overrides))
2233
+ for page_path, content in prepared:
2234
+ atomic_write(page_path, content)
2235
+ atomic_write(paths["index"], generated)
2236
+ agent = normalize_agent_name(payload.get("agent") or args.agent)
2237
+ agent_line = f"\nAgent: `{agent}`\n" if agent else ""
2238
+ block = f"### Session — {timestamp()}\n{agent_line}\n{summary.strip()}\n"
2239
+ daily_file = new_daily_log(paths, "session", block, agent)
2240
+ state["session"]["saved"] = True
2241
+ state["session"]["lastSave"] = timestamp()
2242
+ write_state(paths, state, args, writer_agent=agent)
2243
+ return {
2244
+ "ok": True,
2245
+ "dailyLog": str(daily_file),
2246
+ "pagesWritten": len(prepared),
2247
+ "index": str(paths["index"]),
2248
+ "memorySaved": True,
2249
+ "agent": agent,
2250
+ }
2251
+
2252
+
2253
+ def cmd_hash(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2254
+ require_initialized(paths)
2255
+ filename = normalize_filename(args.filename)
2256
+ page = paths["pages"] / filename
2257
+ if not page.is_file():
2258
+ raise EchoesError(f"Page does not exist: {filename}")
2259
+ content = page.read_text(encoding="utf-8")
2260
+ return {"ok": True, "page": str(page), "sha256": sha256_text(content)}
2261
+
2262
+
2263
+ def cmd_rebuild_index(paths: dict[str, Path], args: argparse.Namespace) -> dict[str, Any]:
2264
+ state = require_initialized(paths)
2265
+ index = refresh_index(paths)
2266
+ write_state(paths, state, args)
2267
+ return {"ok": True, "index": str(paths["index"]), **index}
2268
+
2269
+
2270
+ def build_parser() -> argparse.ArgumentParser:
2271
+ parser = argparse.ArgumentParser(description=__doc__)
2272
+ parser.add_argument(
2273
+ "--workspace",
2274
+ help="Project directory. Defaults to the current Git root, then the current directory.",
2275
+ )
2276
+ parser.add_argument(
2277
+ "--agent",
2278
+ help="Agent writing state or knowledge, for example codex, opencode, or claude.",
2279
+ )
2280
+ parser.add_argument(
2281
+ "--adapter-version",
2282
+ help="Version of the invoking agent adapter; it is metadata, not the engine version.",
2283
+ )
2284
+ subparsers = parser.add_subparsers(dest="command", required=True)
2285
+ subparsers.add_parser("init", help="Create the vault idempotently.")
2286
+ subparsers.add_parser("migrate", help="Explicitly migrate a recognized legacy vault.")
2287
+ subparsers.add_parser("upgrade", help="Upgrade the project runtime and tracked adapters.")
2288
+ subparsers.add_parser("protocol", help="Report protocol compatibility and managed paths.")
2289
+ subparsers.add_parser(
2290
+ "configure-agents",
2291
+ help="Install or repair agent-neutral protocol documentation and adapters.",
2292
+ )
2293
+ start_parser = subparsers.add_parser("start", help="Restore index and recent session entries.")
2294
+ start_parser.add_argument("--recent", type=int, default=3)
2295
+ inspect_parser = subparsers.add_parser(
2296
+ "inspect", help="Inspect health without changing any file."
2297
+ )
2298
+ inspect_parser.add_argument("--format", choices=("json", "card"), default="json")
2299
+ status_parser = subparsers.add_parser(
2300
+ "status", help="Read-only alias for inspect."
2301
+ )
2302
+ status_parser.add_argument("--format", choices=("json", "card"), default="json")
2303
+ subparsers.add_parser(
2304
+ "hydrate", help="Refresh only ignored index.md and local state.json."
2305
+ )
2306
+ search_parser = subparsers.add_parser("search", help="Search page contents literally.")
2307
+ search_parser.add_argument("query")
2308
+ search_parser.add_argument("--limit", type=int, default=100)
2309
+ append_parser = subparsers.add_parser("append", help="Append a scratchpad entry from JSON.")
2310
+ append_parser.add_argument("--payload", default="-", help="JSON file path, or '-' for stdin.")
2311
+ upsert_parser = subparsers.add_parser("upsert", help="Create or safely update one page.")
2312
+ upsert_parser.add_argument("--payload", default="-", help="JSON file path, or '-' for stdin.")
2313
+ end_parser = subparsers.add_parser("end", help="Commit final session memory from JSON.")
2314
+ end_parser.add_argument("--payload", default="-", help="JSON file path, or '-' for stdin.")
2315
+ end_parser.add_argument("--confirm-explicit-user-end", action="store_true")
2316
+ hash_parser = subparsers.add_parser("hash", help="Hash a page for optimistic concurrency.")
2317
+ hash_parser.add_argument("filename")
2318
+ subparsers.add_parser(
2319
+ "rebuild-index", help="Force validation and deterministic index regeneration."
2320
+ )
2321
+ return parser
2322
+
2323
+
2324
+ def emit(value: Any) -> None:
2325
+ if isinstance(value, str):
2326
+ sys.stdout.write(value)
2327
+ return
2328
+ sys.stdout.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
2329
+
2330
+
2331
+ READ_ONLY_COMMANDS = {"inspect", "status", "protocol", "search", "hash"}
2332
+ INSPECTION_COMMANDS = {"inspect", "status", "protocol"}
2333
+ FULL_INSTALL_COMMANDS = {"init", "migrate", "upgrade"}
2334
+
2335
+
2336
+ def execute_project_runtime(
2337
+ target: Path, original_argv: list[str], args: argparse.Namespace
2338
+ ) -> int:
2339
+ input_value: Optional[str] = None
2340
+ if args.command in {"append", "upsert", "end"} and args.payload == "-":
2341
+ input_value = sys.stdin.read()
2342
+ try:
2343
+ result = subprocess.run(
2344
+ [sys.executable, str(target), *original_argv],
2345
+ input=input_value,
2346
+ capture_output=True,
2347
+ text=True,
2348
+ check=False,
2349
+ )
2350
+ except OSError as exc:
2351
+ raise EchoesError(f"Cannot execute project runtime {target}: {exc}") from exc
2352
+ sys.stdout.write(result.stdout)
2353
+ sys.stderr.write(result.stderr)
2354
+ return result.returncode
2355
+
2356
+
2357
+ def delegate_to_project_runtime(
2358
+ paths: dict[str, Path], args: argparse.Namespace, original_argv: list[str]
2359
+ ) -> Optional[int]:
2360
+ source = Path(__file__).resolve()
2361
+ target = paths["runtime"]
2362
+ if source == target.resolve(strict=False):
2363
+ return None
2364
+
2365
+ health = project_runtime_health(paths)
2366
+ source_engine = configured_version(ENGINE_VERSION)
2367
+ target_engine = engine_version_from_content(target.read_text(encoding="utf-8")) if health["recognized"] else None
2368
+
2369
+ if health["recognized"] and health["compatible"] and target_engine is not None:
2370
+ if args.command in FULL_INSTALL_COMMANDS:
2371
+ with vault_lock(paths):
2372
+ ensure_portable_runtime(paths)
2373
+ return execute_project_runtime(target, original_argv, args)
2374
+ if target_engine >= source_engine:
2375
+ return execute_project_runtime(target, original_argv, args)
2376
+ if args.command in INSPECTION_COMMANDS:
2377
+ return None
2378
+ raise EchoesError(
2379
+ f"Project runtime {format_version(target_engine)} is older than engine "
2380
+ f"{ENGINE_VERSION}. Run the echoes-init workflow or `upgrade` explicitly."
2381
+ )
2382
+ elif health["exists"]:
2383
+ if args.command in INSPECTION_COMMANDS:
2384
+ return None
2385
+ if args.command not in FULL_INSTALL_COMMANDS:
2386
+ raise EchoesError(
2387
+ f"Project runtime is incompatible or unrecognized: {health['error'] or health['protocolVersion']}. "
2388
+ "Run the echoes-init workflow or `upgrade` explicitly."
2389
+ )
2390
+ elif args.command in INSPECTION_COMMANDS:
2391
+ return None
2392
+ elif args.command == "hydrate":
2393
+ raise EchoesError(
2394
+ "Project runtime is missing. Run the echoes-init workflow before hydrate."
2395
+ )
2396
+ elif args.command not in FULL_INSTALL_COMMANDS and not marker_health(paths)["valid"]:
2397
+ if legacy_vault_detected(paths):
2398
+ raise EchoesError(
2399
+ "Legacy EchoesVault detected. Run the echoes-init workflow to migrate it."
2400
+ )
2401
+ raise EchoesError(
2402
+ "EchoesVault is not initialized. Run the echoes-init workflow first."
2403
+ )
2404
+
2405
+ with vault_lock(paths):
2406
+ ensure_portable_runtime(paths)
2407
+ installed = project_runtime_health(paths)
2408
+ if not installed["recognized"] or not installed["compatible"]:
2409
+ raise EchoesError(
2410
+ f"Could not install a compatible project runtime: {installed['error']}"
2411
+ )
2412
+ return execute_project_runtime(target, original_argv, args)
2413
+
2414
+
2415
+ def main(argv: Optional[Iterable[str]] = None) -> int:
2416
+ parser = build_parser()
2417
+ original_argv = list(argv) if argv is not None else sys.argv[1:]
2418
+ args = parser.parse_args(original_argv)
2419
+ try:
2420
+ workspace = resolve_workspace(args.workspace)
2421
+ paths = vault_paths(workspace)
2422
+ delegated = delegate_to_project_runtime(paths, args, original_argv)
2423
+ if delegated is not None:
2424
+ return delegated
2425
+ commands = {
2426
+ "init": cmd_init,
2427
+ "migrate": cmd_init,
2428
+ "upgrade": cmd_init,
2429
+ "protocol": cmd_protocol,
2430
+ "configure-agents": cmd_configure_agents,
2431
+ "inspect": cmd_inspect,
2432
+ "hydrate": cmd_hydrate,
2433
+ "start": cmd_start,
2434
+ "status": cmd_status,
2435
+ "search": cmd_search,
2436
+ "append": cmd_append,
2437
+ "upsert": cmd_upsert,
2438
+ "end": cmd_end,
2439
+ "hash": cmd_hash,
2440
+ "rebuild-index": cmd_rebuild_index,
2441
+ }
2442
+ if args.command in READ_ONLY_COMMANDS:
2443
+ emit(commands[args.command](paths, args))
2444
+ else:
2445
+ with vault_lock(paths):
2446
+ emit(commands[args.command](paths, args))
2447
+ return 0
2448
+ except EchoesError as exc:
2449
+ sys.stderr.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False) + "\n")
2450
+ return 2
2451
+
2452
+
2453
+ if __name__ == "__main__":
2454
+ raise SystemExit(main())