echoes-vault-opencode 1.2.2 → 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.
- package/EchoesProtocol.md +1097 -0
- package/README.md +150 -97
- package/index.ts +189 -408
- package/package.json +17 -6
- package/prompts/commands/echoes-end.md +9 -14
- package/prompts/commands/echoes-init.md +7 -31
- package/prompts/commands/echoes-start.md +6 -25
- package/prompts/commands/echoes-status.md +4 -32
- package/runtime.ts +157 -0
- package/scripts/echoes_vault.py +2454 -0
- package/tui.tsx +75 -75
- package/prompts/skills/echoes-append-to-daily-log.md +0 -22
- package/prompts/skills/echoes-create-or-update-page.md +0 -22
- package/prompts/skills/echoes-search-vault-pages.md +0 -19
|
@@ -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())
|