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/index.ts CHANGED
@@ -2,479 +2,260 @@ import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import * as fs from "node:fs/promises"
4
4
  import * as path from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { runEchoes } from "./runtime.ts"
5
7
 
6
- const getDateStr = (): string => {
7
- const d = new Date()
8
- const year = d.getFullYear()
9
- const month = String(d.getMonth() + 1).padStart(2, "0")
10
- const day = String(d.getDate()).padStart(2, "0")
11
- return `${year}-${month}-${day}`
12
- }
13
-
14
- type VaultPaths = {
15
- vault: string
16
- raw: string
17
- pages: string
18
- daily: string
19
- assets: string
20
- }
21
-
22
- type VaultStats = {
23
- totalPages: number
24
- totalDailyLogs: number
25
- deprecatedPages: number
26
- }
27
-
28
- type EchoesState = {
29
- version: number
30
- pluginVersion: string
31
- initialized: boolean
32
- session: {
33
- started: boolean
34
- saved: boolean
35
- lastStart: string | null
36
- lastSave: string | null
37
- }
38
- stats: VaultStats
39
- }
40
-
41
- const STATE_FILENAME = ".opencode/echoes-state.json"
42
-
43
- const getPluginVersion = async (): Promise<string> => {
44
- try {
45
- const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), "package.json")
46
- const pkg = JSON.parse(await fs.readFile(pkgPath, "utf-8"))
47
- return pkg.version || "0.0.0"
48
- } catch {
49
- return "0.0.0"
50
- }
51
- }
52
-
53
- const defaultState = (): EchoesState => ({
54
- version: 1,
55
- pluginVersion: "0.0.0",
56
- initialized: false,
57
- session: {
58
- started: false,
59
- saved: false,
60
- lastStart: null,
61
- lastSave: null,
62
- },
63
- stats: {
64
- totalPages: 0,
65
- totalDailyLogs: 0,
66
- deprecatedPages: 0,
67
- },
68
- })
69
-
70
- const readState = async (directory: string): Promise<EchoesState> => {
71
- try {
72
- const raw = await fs.readFile(path.join(directory, STATE_FILENAME), "utf-8")
73
- return JSON.parse(raw) as EchoesState
74
- } catch {
75
- return defaultState()
76
- }
77
- }
78
-
79
- const writeState = async (directory: string, state: EchoesState): Promise<void> => {
80
- const filePath = path.join(directory, STATE_FILENAME)
81
- await fs.mkdir(path.dirname(filePath), { recursive: true })
82
- await fs.writeFile(filePath, JSON.stringify(state, null, 2))
83
- }
84
-
85
- const resolveVaultPaths = (directory: string): VaultPaths => {
86
- const vault = path.join(directory, "EchoesVault")
87
- return {
88
- vault,
89
- raw: path.join(vault, "raw"),
90
- pages: path.join(vault, "pages"),
91
- daily: path.join(vault, "daily"),
92
- assets: path.join(vault, "assets"),
93
- }
94
- }
95
-
96
- const ensureVaultDirs = async (paths: VaultPaths): Promise<void> => {
97
- await fs.mkdir(paths.raw, { recursive: true })
98
- await fs.mkdir(paths.pages, { recursive: true })
99
- await fs.mkdir(paths.daily, { recursive: true })
100
- await fs.mkdir(paths.assets, { recursive: true })
101
- }
102
-
103
- const collectStats = async (vaultPaths: VaultPaths): Promise<VaultStats> => {
104
- let totalPages = 0
105
- let totalDailyLogs = 0
106
- let deprecatedPages = 0
107
-
108
- try {
109
- const pageFiles = (await fs.readdir(vaultPaths.pages)).filter((f) => f.endsWith(".md"))
110
- totalPages = pageFiles.length
111
- for (const file of pageFiles) {
112
- const content = await fs.readFile(path.join(vaultPaths.pages, file), "utf-8")
113
- if (content.includes("DEPRECATED")) {
114
- deprecatedPages++
115
- }
116
- }
117
- } catch { /* pages dir may not exist yet */ }
118
-
119
- try {
120
- const dailyFiles = (await fs.readdir(vaultPaths.daily)).filter((f) => f.endsWith(".md"))
121
- totalDailyLogs = dailyFiles.length
122
- } catch { /* daily dir may not exist yet */ }
8
+ const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url))
123
9
 
124
- return { totalPages, totalDailyLogs, deprecatedPages }
125
- }
126
-
127
- const updateStats = async (directory: string, vaultPaths: VaultPaths): Promise<void> => {
128
- const st = await readState(directory)
129
- st.stats = await collectStats(vaultPaths)
130
- await writeState(directory, st)
131
- }
132
-
133
- const DEFAULT_INDEX = `# EchoesVault Index
134
-
135
- Welcome to the EchoesVault knowledge base.
136
-
137
- This index tracks all structured pages in the vault.
138
- `
139
-
140
- const sanitizeFilename = (name: string): string => {
141
- const cleaned = name.replace(/\.\./g, "").replace(/[\/\\]/g, "")
142
- return cleaned || "untitled"
143
- }
144
-
145
- // Normalizes any user/LLM-supplied page name to a safe `*.md` filename,
146
- // ensuring we never produce duplicates like `foo.md.md`.
147
- const toPageFilename = (name: string): string => {
148
- const safe = sanitizeFilename(name)
149
- return safe.endsWith(".md") ? safe : `${safe}.md`
150
- }
151
-
152
- const toPageSlug = (filename: string): string => filename.replace(/\.md$/, "")
153
-
154
- const ensureCommands = async (directory: string, commands: Record<string, string>): Promise<void> => {
155
- const cmdDir = path.join(directory, ".opencode", "commands")
156
- await fs.mkdir(cmdDir, { recursive: true })
157
- for (const [name, content] of Object.entries(commands)) {
158
- const cmdFile = path.join(cmdDir, name)
159
- try {
160
- await fs.access(cmdFile)
161
- } catch {
162
- await fs.writeFile(cmdFile, content)
163
- }
164
- }
165
- }
10
+ const parseCommandFrontmatter = (
11
+ command: string,
12
+ ): { template: string; description?: string; agent?: string } => {
13
+ const match = command.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
14
+ if (!match) return { template: command }
166
15
 
167
- const ensureSkills = async (directory: string, skills: Record<string, string>): Promise<void> => {
168
- for (const [name, content] of Object.entries(skills)) {
169
- const skillDir = path.join(directory, ".opencode", "skills", name)
170
- const skillFile = path.join(skillDir, "SKILL.md")
171
- await fs.mkdir(skillDir, { recursive: true })
172
- try {
173
- await fs.access(skillFile)
174
- } catch {
175
- await fs.writeFile(skillFile, content)
176
- }
177
- }
178
- }
179
-
180
- const parseCommandFrontmatter = (cmd: string): { template: string; description?: string; agent?: string } => {
181
- const match = cmd.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
182
- if (!match) return { template: cmd }
183
16
  const frontmatter: Record<string, string> = {}
184
17
  for (const line of match[1].split("\n")) {
185
18
  const [key, ...rest] = line.split(":")
186
19
  if (key && rest.length) frontmatter[key.trim()] = rest.join(":").trim()
187
20
  }
188
- return { template: match[2], description: frontmatter.description, agent: frontmatter.agent }
21
+ return {
22
+ template: match[2],
23
+ description: frontmatter.description,
24
+ agent: frontmatter.agent,
25
+ }
189
26
  }
190
27
 
191
- const OpenCodeEchoes: Plugin = async ({ directory }) => {
192
- const pluginDir = path.dirname(new URL(import.meta.url).pathname)
28
+ const displayOutput = (output: string): string => output.trimEnd()
193
29
 
194
- const readPromptFile = async (relativePath: string): Promise<string> => {
195
- return await fs.readFile(path.join(pluginDir, "prompts", relativePath), "utf-8")
196
- }
30
+ const statusActions = {
31
+ "echoes-init": "init",
32
+ "echoes-start": "start",
33
+ "echoes-end": "end",
34
+ } as const
197
35
 
198
- const ECHOES_INIT = await readPromptFile("commands/echoes-init.md")
199
- const ECHOES_START = await readPromptFile("commands/echoes-start.md")
200
- const ECHOES_END = await readPromptFile("commands/echoes-end.md")
201
- const ECHOES_STATUS = await readPromptFile("commands/echoes-status.md")
36
+ type StatusAction = (typeof statusActions)[keyof typeof statusActions]
202
37
 
203
- const APPEND_TO_DAILY_LOG = await readPromptFile("skills/echoes-append-to-daily-log.md")
204
- const SEARCH_VAULT_PAGES = await readPromptFile("skills/echoes-search-vault-pages.md")
205
- const CREATE_OR_UPDATE_PAGE = await readPromptFile("skills/echoes-create-or-update-page.md")
38
+ const OpenCodeEchoes: Plugin = async ({ directory, worktree }) => {
39
+ const readPromptFile = async (relativePath: string): Promise<string> =>
40
+ await fs.readFile(path.join(PLUGIN_ROOT, "prompts", relativePath), "utf-8")
206
41
 
207
42
  const commands: Record<string, string> = {
208
- "echoes-init.md": ECHOES_INIT,
209
- "echoes-start.md": ECHOES_START,
210
- "echoes-end.md": ECHOES_END,
211
- "echoes-status.md": ECHOES_STATUS,
212
- }
213
- const skills: Record<string, string> = {
214
- "echoes-append-to-daily-log": APPEND_TO_DAILY_LOG,
215
- "echoes-search-vault-pages": SEARCH_VAULT_PAGES,
216
- "echoes-create-or-update-page": CREATE_OR_UPDATE_PAGE,
43
+ "echoes-init.md": await readPromptFile("commands/echoes-init.md"),
44
+ "echoes-start.md": await readPromptFile("commands/echoes-start.md"),
45
+ "echoes-end.md": await readPromptFile("commands/echoes-end.md"),
46
+ "echoes-status.md": await readPromptFile("commands/echoes-status.md"),
217
47
  }
218
48
 
219
- const paths = resolveVaultPaths(directory)
220
- const indexFile = path.join(paths.vault, "index.md")
49
+ // Lifecycle tools are one-shot capabilities granted only by the matching explicit slash command.
50
+ const authorizedStatusActions = new Map<string, StatusAction>()
51
+ const fallbackWorkspace = worktree || directory
221
52
 
222
- await ensureVaultDirs(paths)
223
- try {
224
- await fs.access(indexFile)
225
- } catch {
226
- await fs.writeFile(indexFile, DEFAULT_INDEX)
53
+ const requireAuthorization = (sessionID: string, expected: StatusAction): void => {
54
+ if (authorizedStatusActions.get(sessionID) !== expected) {
55
+ throw new Error(
56
+ `EchoesVault ${expected} requires the explicit /echoes-${expected} command from the user.`,
57
+ )
58
+ }
227
59
  }
228
60
 
229
- await ensureCommands(directory, commands)
230
- await ensureSkills(directory, skills)
231
-
232
- const state = await readState(directory)
233
- state.pluginVersion = await getPluginVersion()
234
- state.session.started = false
235
- state.session.saved = false
236
- state.stats = await collectStats(paths)
237
- await writeState(directory, state)
238
-
239
61
  return {
240
62
  config: async (input) => {
241
- input.command = input.command || {}
242
- for (const [name, cmd] of Object.entries(commands)) {
243
- const { template, description, agent } = parseCommandFrontmatter(cmd)
244
- input.command[name.replace(".md", "")] = { template, description, agent }
63
+ input.command ||= {}
64
+ for (const [name, command] of Object.entries(commands)) {
65
+ const { template, description, agent } = parseCommandFrontmatter(command)
66
+ input.command[name.replace(/\.md$/, "")] = { template, description, agent }
245
67
  }
246
68
  },
69
+
70
+ "command.execute.before": async (input) => {
71
+ const command = input.command.replace(/^\//, "") as keyof typeof statusActions
72
+ const action = statusActions[command]
73
+ if (action) authorizedStatusActions.set(input.sessionID, action)
74
+ },
75
+
247
76
  tool: {
77
+ echoes_activate_vault: tool({
78
+ description:
79
+ "Initialize or explicitly migrate EchoesVault through the shared portable runtime. Available only after /echoes-init.",
80
+ args: {},
81
+ async execute(_args, ctx) {
82
+ requireAuthorization(ctx.sessionID, "init")
83
+ const output = displayOutput(
84
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "init", {
85
+ signal: ctx.abort,
86
+ }),
87
+ )
88
+ authorizedStatusActions.delete(ctx.sessionID)
89
+ return output
90
+ },
91
+ }),
92
+
93
+ echoes_start_session: tool({
94
+ description:
95
+ "Restore the generated index and three most recent EchoesVault session entries. Available only after /echoes-start.",
96
+ args: {},
97
+ async execute(_args, ctx) {
98
+ requireAuthorization(ctx.sessionID, "start")
99
+ const output = displayOutput(
100
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "start", {
101
+ args: ["--recent", "3"],
102
+ signal: ctx.abort,
103
+ }),
104
+ )
105
+ authorizedStatusActions.delete(ctx.sessionID)
106
+ return output
107
+ },
108
+ }),
109
+
110
+ echoes_vault_status: tool({
111
+ description:
112
+ "Inspect EchoesVault protocol, storage, metadata, index, Git readiness, conflicts, and scale without modifying files.",
113
+ args: {},
114
+ async execute(_args, ctx) {
115
+ return displayOutput(
116
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "status", {
117
+ args: ["--format", "card"],
118
+ signal: ctx.abort,
119
+ }),
120
+ )
121
+ },
122
+ }),
123
+
248
124
  commit_memory_to_echoes_vault: tool({
249
125
  description:
250
- "Save session memory to EchoesVault. Writes a daily summary, creates new knowledge base pages, and updates the Vault index. Call this at session end to persist all context.",
126
+ "Finalize an EchoesVault session with one daily summary and optional curated pages. Available only after explicit /echoes-end.",
251
127
  args: {
252
128
  dailySummary: tool.schema
253
129
  .string()
254
- .describe(
255
- "Detailed summary of the current session: what was accomplished, bugs discovered, where you stopped, and what remains to be done."
256
- ),
257
- newPages: tool.schema
130
+ .min(1)
131
+ .describe("Dense final outcomes, unresolved blockers, and next steps; never a transcript."),
132
+ pages: tool.schema
258
133
  .array(
259
134
  tool.schema.object({
260
- filename: tool.schema
261
- .string()
262
- .describe("Filename without .md extension (e.g. 'architecture-decisions')"),
135
+ filename: tool.schema.string().min(1).describe("Safe page filename ending in .md."),
263
136
  content: tool.schema
264
137
  .string()
265
- .describe("Full markdown content of the knowledge base page"),
266
- })
267
- )
268
- .optional()
269
- .describe("Array of new knowledge base pages to create in EchoesVault/pages/"),
270
- indexAppends: tool.schema
271
- .array(tool.schema.string())
272
- .optional()
273
- .describe("Lines to append to the end of EchoesVault/index.md"),
274
- indexUpdates: tool.schema
275
- .array(
276
- tool.schema.object({
277
- oldLine: tool.schema
278
- .string()
279
- .describe("The exact line to find and replace in the index"),
280
- newLine: tool.schema
138
+ .min(1)
139
+ .describe("Complete page with type, stack, status, and summary frontmatter."),
140
+ expectedSha256: tool.schema
281
141
  .string()
282
- .describe("The replacement line"),
283
- })
142
+ .optional()
143
+ .describe("Fresh current hash, required when replacing an existing page."),
144
+ }),
284
145
  )
285
- .optional()
286
- .describe("Lines to find and replace in place within EchoesVault/index.md"),
146
+ .optional(),
287
147
  },
288
- async execute(args, _ctx) {
289
- const today = getDateStr()
290
-
291
- await ensureVaultDirs(paths)
292
-
293
- const dailyFile = path.join(paths.daily, `${today}.md`)
294
- const timestamp = new Date().toISOString()
295
- const header = `## Session — ${timestamp}\n\n`
296
- await fs.appendFile(dailyFile, header + args.dailySummary + "\n\n")
297
-
298
- let pagesCreated = 0
299
- if (args.newPages && args.newPages.length > 0) {
300
- for (const page of args.newPages) {
301
- const fileName = toPageFilename(page.filename)
302
- const pageFile = path.join(paths.pages, fileName)
303
- await fs.writeFile(pageFile, page.content.trim() + "\n")
304
- pagesCreated++
305
- }
306
- }
307
-
308
- const idxFile = path.join(paths.vault, "index.md")
309
-
310
- let indexContent = ""
311
- try {
312
- indexContent = await fs.readFile(idxFile, "utf-8")
313
- } catch {
314
- indexContent = DEFAULT_INDEX
315
- }
316
-
317
- if (args.indexUpdates && args.indexUpdates.length > 0) {
318
- for (const upd of args.indexUpdates) {
319
- if (indexContent.includes(upd.oldLine)) {
320
- indexContent = indexContent.replaceAll(upd.oldLine, upd.newLine)
321
- }
322
- }
323
- }
324
-
325
- if (args.indexAppends && args.indexAppends.length > 0) {
326
- const toAppend = args.indexAppends.join("\n")
327
- indexContent = indexContent.trimEnd() + "\n" + toAppend + "\n"
328
- }
329
-
330
- await fs.writeFile(idxFile, indexContent)
331
-
332
- const st = await readState(directory)
333
- st.session.saved = true
334
- st.session.lastSave = new Date().toISOString()
335
- st.stats = await collectStats(paths)
336
- await writeState(directory, st)
337
-
338
- return [
339
- `✅ Memory committed to EchoesVault.`,
340
- `- Daily log: EchoesVault/daily/${today}.md`,
341
- `- Pages created: ${pagesCreated}`,
342
- `- Index: updated`,
343
- ].join("\n")
148
+ async execute(args, ctx) {
149
+ requireAuthorization(ctx.sessionID, "end")
150
+ const output = displayOutput(
151
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "end", {
152
+ args: ["--confirm-explicit-user-end", "--payload", "-"],
153
+ payload: {
154
+ dailySummary: args.dailySummary,
155
+ pages: args.pages ?? [],
156
+ },
157
+ signal: ctx.abort,
158
+ }),
159
+ )
160
+ authorizedStatusActions.delete(ctx.sessionID)
161
+ return output
344
162
  },
345
163
  }),
164
+
346
165
  echoes_append_to_daily_log: tool({
347
166
  description:
348
- "Append an intermediate technical note or decision to today's daily log without ending the session.",
167
+ "Write one intermediate technical milestone to a unique EchoesVault daily entry without ending the session.",
349
168
  args: {
350
169
  logEntry: tool.schema
351
170
  .string()
352
- .describe(
353
- "Markdown-formatted bullet points to append. Do not include date/time — the system adds a timestamp automatically."
354
- ),
171
+ .min(1)
172
+ .describe("Concise Markdown facts, decisions, blockers, or next steps."),
355
173
  },
356
- async execute(args, _ctx) {
357
- const today = getDateStr()
358
- await ensureVaultDirs(paths)
359
- const dailyFile = path.join(paths.daily, `${today}.md`)
360
- const timestamp = new Date().toISOString()
361
- const entry = `### Scratchpad — ${timestamp}\n\n${args.logEntry}\n\n`
362
- await fs.appendFile(dailyFile, entry)
363
- await updateStats(directory, paths)
364
- return `✅ Scratchpad note saved to EchoesVault/daily/${today}.md`
174
+ async execute(args, ctx) {
175
+ return displayOutput(
176
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "append", {
177
+ args: ["--payload", "-"],
178
+ payload: { entry: args.logEntry },
179
+ signal: ctx.abort,
180
+ }),
181
+ )
365
182
  },
366
183
  }),
184
+
367
185
  echoes_search_vault_pages: tool({
368
186
  description:
369
- "Search the EchoesVault pages/ directory for specific concepts, keywords, or implementation details.",
187
+ "Search EchoesVault knowledge pages with a narrow keyword or phrase before reading a relevant page.",
370
188
  args: {
371
- query: tool.schema
372
- .string()
373
- .describe("Specific keyword or short phrase to search for across the pages/ directory."),
189
+ query: tool.schema.string().min(1).describe("Specific keyword or short phrase."),
190
+ limit: tool.schema.number().int().min(1).max(500).optional(),
374
191
  },
375
- async execute(args, _ctx) {
376
- await ensureVaultDirs(paths)
377
- const results: string[] = []
378
- try {
379
- const files = (await fs.readdir(paths.pages)).filter((f) =>
380
- f.endsWith(".md")
381
- )
382
- for (const file of files) {
383
- const content = await fs.readFile(
384
- path.join(paths.pages, file),
385
- "utf-8"
386
- )
387
- const lines = content.split("\n")
388
- for (let i = 0; i < lines.length; i++) {
389
- if (
390
- lines[i].toLowerCase().includes(args.query.toLowerCase())
391
- ) {
392
- results.push(
393
- `${file}:${i + 1}: ${lines[i].trim().slice(0, 200)}`
394
- )
395
- }
396
- }
397
- }
398
- } catch {
399
- return "_No pages found in EchoesVault/pages/_"
400
- }
401
- if (results.length === 0) {
402
- return `No results found for "${args.query}" in EchoesVault/pages/.`
403
- }
404
- return results.join("\n")
192
+ async execute(args, ctx) {
193
+ const runtimeArgs = [args.query]
194
+ if (args.limit !== undefined) runtimeArgs.push("--limit", String(args.limit))
195
+ return displayOutput(
196
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "search", {
197
+ args: runtimeArgs,
198
+ signal: ctx.abort,
199
+ }),
200
+ )
405
201
  },
406
202
  }),
203
+
204
+ echoes_hash_vault_page: tool({
205
+ description:
206
+ "Calculate the current SHA-256 of an EchoesVault page before replacing that existing page.",
207
+ args: {
208
+ filename: tool.schema.string().min(1).describe("Existing page filename."),
209
+ },
210
+ async execute(args, ctx) {
211
+ return displayOutput(
212
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "hash", {
213
+ args: [args.filename],
214
+ signal: ctx.abort,
215
+ }),
216
+ )
217
+ },
218
+ }),
219
+
407
220
  echoes_create_or_update_page: tool({
408
221
  description:
409
- "Atomically create a new markdown page or update an existing one in EchoesVault/pages/, automatically updating the index if the file is new.",
222
+ "Create a validated EchoesVault page or replace one using its fresh expected SHA-256; the runtime regenerates the index.",
410
223
  args: {
411
- filename: tool.schema
412
- .string()
413
- .describe("Exact filename without paths (e.g. 'auth-architecture.md')."),
224
+ filename: tool.schema.string().min(1).describe("Exact page filename without paths."),
414
225
  content: tool.schema
415
226
  .string()
416
- .describe("Full markdown content of the page, starting with YAML frontmatter."),
417
- indexDescription: tool.schema
227
+ .min(1)
228
+ .describe("Complete Markdown page with all required frontmatter."),
229
+ expectedSha256: tool.schema
418
230
  .string()
419
231
  .optional()
420
- .describe("One-sentence description for the index. Required for new files. Format: '- [[filename]]: description'."),
421
- },
422
- async execute(args, _ctx) {
423
- await ensureVaultDirs(paths)
424
- const fileName = toPageFilename(args.filename)
425
- const pageFile = path.join(paths.pages, fileName)
426
-
427
- const existed = await fs.access(pageFile).then(() => true).catch(() => false)
428
- await fs.writeFile(pageFile, args.content.trim() + "\n")
429
-
430
- if (!existed && args.indexDescription) {
431
- const idxFile = path.join(paths.vault, "index.md")
432
- let indexContent = ""
433
- try {
434
- indexContent = await fs.readFile(idxFile, "utf-8")
435
- } catch {
436
- indexContent = DEFAULT_INDEX
437
- }
438
- const link = `[[${toPageSlug(fileName)}]]`
439
- if (!indexContent.includes(link)) {
440
- indexContent = indexContent.trimEnd() + "\n" + args.indexDescription + "\n"
441
- await fs.writeFile(idxFile, indexContent)
442
- }
443
- }
444
-
445
- await updateStats(directory, paths)
446
-
447
- const action = existed ? "updated" : "created"
448
- const parts = [`✅ Page ${action}: EchoesVault/pages/${fileName}`]
449
- if (!existed && args.indexDescription) {
450
- parts.push(`📑 Index: synced`)
451
- }
452
- return parts.join("\n")
232
+ .describe("Fresh current hash, required when replacing an existing page."),
453
233
  },
454
- }),
455
- echoes_activate_vault: tool({
456
- description:
457
- "Mark the EchoesVault as activated. Called automatically during /echoes-init to register the vault in the status tracker.",
458
- args: {},
459
- async execute(_args, _ctx) {
460
- const st = await readState(directory)
461
- st.initialized = true
462
- st.stats = await collectStats(paths)
463
- await writeState(directory, st)
464
- return "EchoesVault activated."
234
+ async execute(args, ctx) {
235
+ return displayOutput(
236
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "upsert", {
237
+ args: ["--payload", "-"],
238
+ payload: {
239
+ filename: args.filename,
240
+ content: args.content,
241
+ ...(args.expectedSha256 ? { expectedSha256: args.expectedSha256 } : {}),
242
+ },
243
+ signal: ctx.abort,
244
+ }),
245
+ )
465
246
  },
466
247
  }),
467
- echoes_start_session: tool({
248
+
249
+ echoes_hydrate_vault: tool({
468
250
  description:
469
- "Mark the current EchoesVault session as started. Called automatically during /echoes-start to update the status tracker.",
251
+ "Rebuild only ignored local EchoesVault index/state files for an already initialized checkout.",
470
252
  args: {},
471
- async execute(_args, _ctx) {
472
- const st = await readState(directory)
473
- st.session.started = true
474
- st.session.saved = false
475
- st.session.lastStart = new Date().toISOString()
476
- await writeState(directory, st)
477
- return "EchoesVault session started."
253
+ async execute(_args, ctx) {
254
+ return displayOutput(
255
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "hydrate", {
256
+ signal: ctx.abort,
257
+ }),
258
+ )
478
259
  },
479
260
  }),
480
261
  },