echoes-vault-opencode 1.2.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 { 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
8
+ const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url))
134
9
 
135
- Welcome to the EchoesVault knowledge base.
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 }
136
15
 
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
- }
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,227 @@ 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,
220
- }
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,
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"),
225
47
  }
226
48
 
227
- const paths = resolveVaultPaths(directory)
228
- const indexFile = path.join(paths.vault, "index.md")
229
-
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>()
51
+ const fallbackWorkspace = worktree || directory
242
52
 
243
- const state = await readState(directory)
244
- state.pluginVersion = await getPluginVersion()
245
- state.stats = await collectStats(paths)
246
- await writeState(directory, state)
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
+ }
59
+ }
247
60
 
248
61
  return {
249
62
  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 }
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 }
254
67
  }
255
68
  },
69
+
256
70
  "command.execute.before": async (input) => {
257
71
  const command = input.command.replace(/^\//, "") as keyof typeof statusActions
258
72
  const action = statusActions[command]
259
- if (action) {
260
- authorizedStatusActions.set(input.sessionID, action)
261
- }
73
+ if (action) authorizedStatusActions.set(input.sessionID, action)
262
74
  },
75
+
263
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
+
264
124
  commit_memory_to_echoes_vault: tool({
265
125
  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.",
126
+ "Finalize an EchoesVault session with one daily summary and optional curated pages. Available only after explicit /echoes-end.",
267
127
  args: {
268
128
  dailySummary: tool.schema
269
129
  .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
130
+ .min(1)
131
+ .describe("Dense final outcomes, unresolved blockers, and next steps; never a transcript."),
132
+ pages: tool.schema
274
133
  .array(
275
134
  tool.schema.object({
276
- filename: tool.schema
277
- .string()
278
- .describe("Filename without .md extension (e.g. 'architecture-decisions')"),
135
+ filename: tool.schema.string().min(1).describe("Safe page filename ending in .md."),
279
136
  content: tool.schema
280
137
  .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
138
+ .min(1)
139
+ .describe("Complete page with type, stack, status, and summary frontmatter."),
140
+ expectedSha256: tool.schema
297
141
  .string()
298
- .describe("The replacement line"),
299
- })
142
+ .optional()
143
+ .describe("Fresh current hash, required when replacing an existing page."),
144
+ }),
300
145
  )
301
- .optional()
302
- .describe("Lines to find and replace in place within EchoesVault/index.md"),
146
+ .optional(),
303
147
  },
304
148
  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)
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
+ )
360
160
  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")
161
+ return output
368
162
  },
369
163
  }),
164
+
370
165
  echoes_append_to_daily_log: tool({
371
166
  description:
372
- "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.",
373
168
  args: {
374
169
  logEntry: tool.schema
375
170
  .string()
376
- .describe(
377
- "Markdown-formatted bullet points to append. Do not include date/time — the system adds a timestamp automatically."
378
- ),
171
+ .min(1)
172
+ .describe("Concise Markdown facts, decisions, blockers, or next steps."),
379
173
  },
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`
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
+ )
389
182
  },
390
183
  }),
184
+
391
185
  echoes_search_vault_pages: tool({
392
186
  description:
393
- "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.",
394
188
  args: {
395
- query: tool.schema
396
- .string()
397
- .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(),
398
191
  },
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")
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
+ )
201
+ },
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
+ )
429
217
  },
430
218
  }),
219
+
431
220
  echoes_create_or_update_page: tool({
432
221
  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.",
222
+ "Create a validated EchoesVault page or replace one using its fresh expected SHA-256; the runtime regenerates the index.",
434
223
  args: {
435
- filename: tool.schema
436
- .string()
437
- .describe("Exact filename without paths (e.g. 'auth-architecture.md')."),
224
+ filename: tool.schema.string().min(1).describe("Exact page filename without paths."),
438
225
  content: tool.schema
439
226
  .string()
440
- .describe("Full markdown content of the page, starting with YAML frontmatter."),
441
- indexDescription: tool.schema
227
+ .min(1)
228
+ .describe("Complete Markdown page with all required frontmatter."),
229
+ expectedSha256: tool.schema
442
230
  .string()
443
231
  .optional()
444
- .describe("One-sentence description for the index. Required for new files. Format: '- [[filename]]: description'."),
232
+ .describe("Fresh current hash, required when replacing an existing page."),
445
233
  },
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")
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
+ )
477
246
  },
478
247
  }),
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
248
 
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({
249
+ echoes_hydrate_vault: tool({
497
250
  description:
498
- "Mark the current EchoesVault session as started. This succeeds only after the user explicitly runs /echoes-start.",
251
+ "Rebuild only ignored local EchoesVault index/state files for an already initialized checkout.",
499
252
  args: {},
500
253
  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."
254
+ return displayOutput(
255
+ await runEchoes(ctx.worktree || ctx.directory || fallbackWorkspace, "hydrate", {
256
+ signal: ctx.abort,
257
+ }),
258
+ )
512
259
  },
513
260
  }),
514
261
  },