echoes-vault-opencode 1.2.3 → 2.0.1

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