orquestra-mcp 1.55.0 → 1.56.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.
@@ -15,8 +15,10 @@ Uso:
15
15
  Lo mismo para una herramienta puntual (claude-code, codex,
16
16
  cursor, gemini-cli...): cada una firma con su propio agente
17
17
  npx orquestra-mcp vault sync <carpeta> [--apply]
18
- Sincroniza la base de conocimiento del workspace con una
19
- carpeta de tu vault de Obsidian (la 1ra vez solo muestra el plan)
18
+ Sincroniza la base de conocimiento del workspace (notas e
19
+ imágenes/videos) con una carpeta de tu vault de Obsidian.
20
+ La 1ra vez solo muestra el plan; --help lista las opciones
21
+ (--dry-run, --no-attachments, --verbose)
20
22
  npx orquestra-mcp --help Muestra esta ayuda
21
23
 
22
24
  Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
4
4
  "description": "Servidor MCP de Orquestra -- 135 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,190 @@
1
+ // Imágenes y videos del vault de Obsidian (ORQ-299): se suben a S3 como adjuntos de la
2
+ // entrada de conocimiento (`entityType: 'knowledge'`, /v1/attachments/upload-url +
3
+ // POST /v1/knowledge/{id}/attachments) y se bajan de vuelta al vault en otra máquina.
4
+ //
5
+ // El texto de la nota NO se reescribe: `![[foto.png]]` / `![](img/foto.png)` quedan tal cual,
6
+ // porque el adjunto guarda como `fileName` la ruta del archivo relativa al vault. Así un
7
+ // `vault sync` en otra Mac vuelve a poner cada archivo donde Obsidian lo espera.
8
+ //
9
+ // Reglas (iguales a las del adjunto normal, `add_attachment`): png/jpg/jpeg/gif/webp hasta
10
+ // 15 MB y mp4/mov/webm hasta 500 MB. Cualquier otro tipo (pdf, audio, zip...) se reporta y no
11
+ // se sincroniza. Nunca se borra un archivo del vault; solo se reemplaza en S3 la versión
12
+ // anterior de un archivo que cambió.
13
+ import { createHash } from 'node:crypto'
14
+ import { promises as fs } from 'node:fs'
15
+ import path from 'node:path'
16
+ import { apiGet, apiPost, apiDelete, putToPresignedUrl } from './api.mjs'
17
+
18
+ export const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
19
+ export const VIDEO_MIME = { mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm' }
20
+ export const MAX_IMAGE_BYTES = 15 * 1024 * 1024
21
+ export const MAX_VIDEO_BYTES = 500 * 1024 * 1024
22
+
23
+ const toPosix = p => p.split(path.sep).join('/')
24
+ const extOf = p => path.extname(p).slice(1).toLowerCase()
25
+
26
+ // ── Referencias (puro) ──────────────────────────────────────────────────────
27
+
28
+ /** Referencias a archivos incrustados en el texto de una nota: `![[x|alias]]` y `![](ruta)` (no URLs). */
29
+ export function embedRefs(body) {
30
+ const refs = []
31
+ for (const m of (body || '').matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:|data:)([^)]+)\)/g)) {
32
+ let ref = m[1]
33
+ if (ref !== undefined) {
34
+ ref = ref.split('|')[0].split('#')[0].trim()
35
+ } else {
36
+ ref = m[2].trim().replace(/^<|>$/g, '').replace(/\s+"[^"]*"$/, '')
37
+ try { ref = decodeURIComponent(ref) } catch { /* se queda como venía */ }
38
+ ref = ref.split('#')[0]
39
+ }
40
+ if (ref) refs.push(ref)
41
+ }
42
+ return [...new Set(refs)]
43
+ }
44
+
45
+ /** Índice de archivos del vault para resolver referencias: { all: Set<rel>, byBase: Map<lowerBasename, rel[]> }. */
46
+ export function buildFileIndex(relPaths) {
47
+ const all = new Set(relPaths)
48
+ const byBase = new Map()
49
+ for (const rel of relPaths) {
50
+ const key = path.posix.basename(rel).toLowerCase()
51
+ byBase.set(key, [...(byBase.get(key) || []), rel].sort())
52
+ }
53
+ return { all, byBase }
54
+ }
55
+
56
+ /**
57
+ * Ruta (relativa al vault) del archivo al que apunta `ref`, o null si no existe.
58
+ * Mismo orden que Obsidian: ruta desde la raíz del vault, ruta relativa a la nota y, por último,
59
+ * solo el nombre (el más cercano a la nota si hay varios con el mismo nombre).
60
+ */
61
+ export function resolveRef(ref, noteFile, index) {
62
+ const clean = ref.replace(/\\/g, '/').replace(/^\/+/, '')
63
+ if (index.all.has(clean)) return clean
64
+ const near = path.posix.normalize(path.posix.join(path.posix.dirname(toPosix(noteFile)), clean))
65
+ if (!near.startsWith('..') && index.all.has(near)) return near
66
+ const candidates = index.byBase.get(path.posix.basename(clean).toLowerCase())
67
+ if (!candidates?.length) return null
68
+ const dir = path.posix.dirname(toPosix(noteFile))
69
+ return candidates.find(c => path.posix.dirname(c) === dir) || candidates[0]
70
+ }
71
+
72
+ /** ¿Se sincroniza este archivo? → { ok, kind, mime } o { ok:false, reason: 'unsupported' | 'toolarge' }. */
73
+ export function classifyFile(rel, size) {
74
+ const ext = extOf(rel)
75
+ const image = IMAGE_MIME[ext]
76
+ const video = VIDEO_MIME[ext]
77
+ if (!image && !video) return { ok: false, reason: 'unsupported' }
78
+ const max = image ? MAX_IMAGE_BYTES : MAX_VIDEO_BYTES
79
+ if (size > max) return { ok: false, reason: 'toolarge', maxMb: max / 1024 / 1024 }
80
+ return { ok: true, kind: image ? 'image' : 'video', mime: image || video }
81
+ }
82
+
83
+ /** Seguro para escribir dentro del vault: relativa, sin `..` ni rutas absolutas. */
84
+ export function isSafeRelPath(rel) {
85
+ if (!rel || path.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) return false
86
+ const norm = path.posix.normalize(String(rel).replace(/\\/g, '/'))
87
+ return !norm.startsWith('..') && !norm.startsWith('/') && !norm.split('/').includes('.git')
88
+ }
89
+
90
+ // ── Plan de subida (puro) ───────────────────────────────────────────────────
91
+
92
+ /**
93
+ * notes: [{file, id|null, title, body}]
94
+ * files: Map<rel, {size, hash}> (solo hace falta de los archivos referenciados; ver `fileInfoFor`)
95
+ * index: salida de buildFileIndex
96
+ * attState: { [entryId]: { [rel]: {attachmentId, hash} } }
97
+ * → [{ note, rel, status, ... }] con status:
98
+ * upload (nuevo) | replace (cambió) | unchanged | missing | unsupported | toolarge
99
+ * Una misma imagen usada en dos notas se sube una vez por nota (cada entrada tiene sus adjuntos).
100
+ */
101
+ export function planAttachmentUploads({ notes, files, index, attState = {} }) {
102
+ const out = []
103
+ for (const note of notes) {
104
+ for (const ref of embedRefs(note.body)) {
105
+ const ext = extOf(ref)
106
+ // `![[Otra nota]]` (transclusión) no tiene extensión de archivo: no es un adjunto.
107
+ if (!ext || ext === 'md') continue
108
+ const rel = resolveRef(ref, note.file, index)
109
+ if (!rel) { out.push({ note, ref, rel: null, status: 'missing' }); continue }
110
+ const info = files.get(rel)
111
+ const cls = classifyFile(rel, info?.size ?? 0)
112
+ if (!cls.ok) { out.push({ note, ref, rel, status: cls.reason, size: info?.size, maxMb: cls.maxMb }); continue }
113
+ const prev = note.id ? attState[note.id]?.[rel] : null
114
+ const status = !prev ? 'upload' : prev.hash === info.hash ? 'unchanged' : 'replace'
115
+ out.push({ note, ref, rel, status, size: info.size, hash: info.hash, kind: cls.kind, mime: cls.mime, prev })
116
+ }
117
+ }
118
+ return out
119
+ }
120
+
121
+ // ── Disco ────────────────────────────────────────────────────────────────────
122
+
123
+ /** Todos los archivos NO .md del vault (rutas relativas, posix). Las carpetas/archivos que empiezan con "." se saltan (.obsidian). */
124
+ export async function scanVaultFiles(dir, rel = '') {
125
+ const out = []
126
+ for (const ent of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
127
+ if (ent.name.startsWith('.')) continue
128
+ const relPath = path.join(rel, ent.name)
129
+ if (ent.isDirectory()) out.push(...await scanVaultFiles(dir, relPath))
130
+ else if (!ent.name.endsWith('.md')) out.push(toPosix(relPath))
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** Tamaño y hash de contenido de los archivos referenciados (solo los que existen). */
136
+ export async function fileInfoFor(dir, rels) {
137
+ const info = new Map()
138
+ for (const rel of new Set(rels)) {
139
+ try {
140
+ const buf = await fs.readFile(path.join(dir, rel))
141
+ info.set(rel, { size: buf.length, hash: createHash('sha1').update(buf).digest('hex') })
142
+ } catch { /* desaparecido entre el escaneo y la lectura: queda como missing */ }
143
+ }
144
+ return info
145
+ }
146
+
147
+ // ── Red ──────────────────────────────────────────────────────────────────────
148
+
149
+ /** Adjuntos de una entrada en Orquestra, uno por ruta (si hay repetidos gana el más reciente). */
150
+ export async function listRemoteAttachments(config, projectId, entryId) {
151
+ const { items } = await apiGet(config, `/v1/knowledge/${entryId}/attachments`, { projectId })
152
+ const byPath = new Map()
153
+ for (const a of items || []) {
154
+ if ((a.kind !== 'image' && a.kind !== 'video') || !a.key || !a.fileName) continue
155
+ const prev = byPath.get(a.fileName)
156
+ if (!prev || (a.createdAt || 0) > (prev.createdAt || 0)) byPath.set(a.fileName, a)
157
+ }
158
+ return byPath
159
+ }
160
+
161
+ /** Sube un archivo del vault como adjunto de la entrada; si reemplaza uno anterior, borra la versión vieja. */
162
+ export async function uploadAttachment(config, projectId, entryId, dir, item) {
163
+ const buffer = await fs.readFile(path.join(dir, item.rel))
164
+ const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
165
+ projectId, entityType: 'knowledge', entityId: entryId,
166
+ fileName: item.rel, fileType: item.mime, fileSize: item.size,
167
+ })
168
+ await putToPresignedUrl(uploadUrl, buffer, item.mime)
169
+ const created = await apiPost(config, `/v1/knowledge/${entryId}/attachments`, {
170
+ projectId, kind: item.kind, key, fileName: item.rel, fileType: item.mime, fileSize: item.size,
171
+ })
172
+ if (item.prev?.attachmentId) {
173
+ // Es la versión anterior del MISMO archivo (el id sale de nuestro estado): la nueva ya quedó guardada.
174
+ await apiDelete(config, `/v1/knowledge/${entryId}/attachments`, { projectId, id: item.prev.attachmentId }).catch(() => {})
175
+ }
176
+ return { attachmentId: created.id, hash: item.hash, size: item.size }
177
+ }
178
+
179
+ /** Baja un adjunto al vault SIN pisar un archivo que ya exista. Devuelve true si lo escribió. */
180
+ export async function downloadAttachment(config, projectId, dir, attachment) {
181
+ if (!isSafeRelPath(attachment.fileName)) throw new Error(`ruta no segura en Orquestra: ${attachment.fileName}`)
182
+ const target = path.join(dir, attachment.fileName)
183
+ if (await fs.access(target).then(() => true, () => false)) return false
184
+ const { url } = await apiGet(config, '/v1/attachments/view-url', { projectId, key: attachment.key })
185
+ const res = await fetch(url, { signal: AbortSignal.timeout(120_000) })
186
+ if (!res.ok) throw new Error(`HTTP ${res.status} al bajar ${attachment.fileName}`)
187
+ await fs.mkdir(path.dirname(target), { recursive: true })
188
+ await fs.writeFile(target, Buffer.from(await res.arrayBuffer()))
189
+ return true
190
+ }
package/src/vaultSync.mjs CHANGED
@@ -13,11 +13,16 @@
13
13
  // como "<nombre>.conflict.md" junto al archivo. Nada se borra nunca: una
14
14
  // entrada borrada en el vault se vuelve a bajar, y un .md con orquestraId que
15
15
  // ya no existe en Orquestra solo se reporta.
16
- // Fuera de alcance v1: adjuntos/imágenes del vault (se reportan, no se suben).
16
+ // Imágenes y videos del vault (ORQ-299): se suben a S3 como adjuntos de la entrada y se bajan
17
+ // de vuelta en otra máquina -- ver src/vaultAttachments.mjs. `--no-attachments` los salta.
17
18
  import { createHash } from 'node:crypto'
18
19
  import { promises as fs } from 'node:fs'
19
20
  import path from 'node:path'
20
21
  import { loadConfig, apiGet, apiPost, apiPatch } from './api.mjs'
22
+ import {
23
+ buildFileIndex, planAttachmentUploads, scanVaultFiles, fileInfoFor, embedRefs, resolveRef,
24
+ listRemoteAttachments, uploadAttachment, downloadAttachment, isSafeRelPath,
25
+ } from './vaultAttachments.mjs'
21
26
 
22
27
  export const STATE_FILE = '.orquestra-sync.json'
23
28
  const CONFLICT_SUFFIX = '.conflict.md'
@@ -142,7 +147,6 @@ async function scanVault(dir, rel = '') {
142
147
  async function readLocal(dir) {
143
148
  const files = await scanVault(dir)
144
149
  const notes = []
145
- const attachments = []
146
150
  for (const file of files) {
147
151
  const full = path.join(dir, file)
148
152
  const [text, stat] = await Promise.all([fs.readFile(full, 'utf8'), fs.stat(full)])
@@ -152,9 +156,8 @@ async function readLocal(dir) {
152
156
  const note = { file, title, body, tags: meta.tags, id: meta.orquestraId || null, mtimeMs: stat.mtimeMs, extra }
153
157
  note.hash = contentHash(note)
154
158
  notes.push(note)
155
- for (const emb of body.matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:)([^)]+)\)/g)) attachments.push({ file, ref: emb[1] || emb[2] })
156
159
  }
157
- return { notes, attachments }
160
+ return { notes }
158
161
  }
159
162
 
160
163
  async function readState(dir) {
@@ -169,18 +172,154 @@ function uniqueFile(title, taken) {
169
172
  return name
170
173
  }
171
174
 
175
+
176
+ // ── Reporte (puro) ───────────────────────────────────────────────────────────
177
+
178
+ const LABEL = {
179
+ pull: '↓ Bajar de Orquestra al vault',
180
+ push: '↑ Subir del vault a Orquestra',
181
+ create: '↑ Crear en Orquestra (notas nuevas del vault)',
182
+ conflict: '⚠ Conflictos (cambiaron en los dos lados)',
183
+ orphan: '? Sin entrada en Orquestra',
184
+ }
185
+ const OP_ORDER = ['pull', 'push', 'create', 'conflict', 'orphan']
186
+ const DONE_LABEL = { pull: '↓ bajada', push: '↑ subida', create: '↑ creada', conflict: '⚠ conflicto resuelto', orphan: '? sin entrada' }
187
+
188
+ export function formatBytes(n) {
189
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
190
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`
191
+ return `${n} B`
192
+ }
193
+
194
+ export function ago(ms, now = Date.now()) {
195
+ const min = Math.floor((now - ms) / 60000)
196
+ if (min < 1) return 'hace un momento'
197
+ if (min < 60) return `hace ${min} min`
198
+ const h = Math.round(min / 60)
199
+ if (h < 24) return `hace ${h} h`
200
+ return `hace ${Math.round(h / 24)} d`
201
+ }
202
+
203
+ const itemName = p => p.local?.file || p.remote?.title
204
+
205
+ /**
206
+ * Texto del reporte antes de aplicar (o la vista previa). Todo lo que decide el usuario está aquí:
207
+ * qué pasa con cada nota y cada archivo, por qué, y el siguiente paso.
208
+ * att: { enabled, uploads:[{rel,title,size,status}], downloads:[{rel,title,size?}], unchanged:n,
209
+ * missing:[{rel,title}], unsupported:[{rel,title}], toolarge:[{rel,title,size,maxMb}] }
210
+ */
211
+ export function renderReport({ dir, dirArg, apply, firstRun, lastSyncAt, remoteCount, localCount, plan, att, verbose = false, now = Date.now() }) {
212
+ const out = []
213
+ out.push(`Vault: ${dir}`)
214
+ out.push(`Orquestra: ${remoteCount} entradas · Vault: ${localCount} notas · ${firstRun ? 'Primera sincronización' : `Última sincronización ${ago(lastSyncAt, now)}`}`)
215
+ out.push(apply ? 'Modo: APLICAR CAMBIOS' : 'Modo: VISTA PREVIA -- no se escribe nada todavía')
216
+ if (firstRun) {
217
+ out.push('', 'Cómo funciona:',
218
+ ' • Cada nota del vault es una entrada de la base de conocimiento de tu workspace (el nombre del archivo es el título).',
219
+ ' • Al sincronizar se añade un bloque de propiedades (frontmatter) con orquestraId; no toques esas líneas.',
220
+ ' • NUNCA se borra nada: una nota que borres del vault se vuelve a bajar, y si cambia en los dos lados se guarda una copia .conflict.md.')
221
+ }
222
+
223
+ out.push('', `NOTAS -- ${plan.length === 0 ? 'todo al día' : `${plan.length} cambio${plan.length === 1 ? '' : 's'}`}`)
224
+ for (const op of OP_ORDER) {
225
+ const items = plan.filter(p => p.op === op)
226
+ if (!items.length) continue
227
+ out.push(` ${LABEL[op]} (${items.length})`)
228
+ for (const p of items) {
229
+ const why = op === 'conflict' ? `gana ${p.winner === 'local' ? 'la versión del vault' : 'la de Orquestra'}; la otra queda como .conflict.md` : p.reason
230
+ out.push(` • ${itemName(p)}${why ? ` -- ${why}` : ''}`)
231
+ }
232
+ if (op === 'orphan') out.push(' (tiene orquestraId pero esa entrada ya no existe en Orquestra; no se toca. Para crearla de nuevo, borra la línea orquestraId del archivo.)')
233
+ }
234
+
235
+ if (att.enabled) {
236
+ const nothing = !att.uploads.length && !att.downloads.length && !att.missing.length && !att.unsupported.length && !att.toolarge.length
237
+ out.push('', `IMÁGENES Y VIDEOS -- ${nothing ? (att.unchanged ? `${att.unchanged} ya sincronizados` : 'ninguno referenciado en las notas') : 'cambios'}`)
238
+ if (att.uploads.length) {
239
+ const bytes = att.uploads.reduce((a, u) => a + (u.size || 0), 0)
240
+ out.push(` ↑ Subir a Orquestra (${att.uploads.length}, ${formatBytes(bytes)})`)
241
+ for (const u of att.uploads) out.push(` • ${u.rel} (${formatBytes(u.size || 0)}, en "${u.title}"${u.status === 'replace' ? ', cambió: se reemplaza la versión anterior' : ''})`)
242
+ }
243
+ if (att.downloads.length) {
244
+ out.push(` ↓ Bajar al vault (${att.downloads.length})`)
245
+ for (const d of att.downloads) out.push(` • ${d.rel} (en "${d.title}")`)
246
+ }
247
+ if (att.unchanged && !nothing) out.push(` ✓ ${att.unchanged} ya sincronizados`)
248
+ if (att.missing.length) {
249
+ out.push(` ✗ Referenciados pero no existen en el vault (${att.missing.length}) -- revisa el nombre o la ruta`)
250
+ for (const m of att.missing) out.push(` • ${m.rel || m.ref} (en "${m.title}")`)
251
+ }
252
+ if (att.unsupported.length) {
253
+ out.push(` – No se sincronizan (${att.unsupported.length}): solo png/jpg/gif/webp y mp4/mov/webm`)
254
+ for (const m of att.unsupported) out.push(` • ${m.rel} (en "${m.title}")`)
255
+ }
256
+ if (att.toolarge.length) {
257
+ out.push(` – Demasiado grandes (${att.toolarge.length})`)
258
+ for (const m of att.toolarge) out.push(` • ${m.rel} (${formatBytes(m.size || 0)}, máximo ${m.maxMb} MB)`)
259
+ }
260
+ } else {
261
+ out.push('', 'IMÁGENES Y VIDEOS -- omitidos (--no-attachments)')
262
+ }
263
+
264
+ const total = plan.length + (att.uploads?.length || 0) + (att.downloads?.length || 0)
265
+ out.push('')
266
+ if (!apply) {
267
+ if (total) out.push(`Siguiente paso: revisa la lista y aplica con\n orquestra-mcp vault sync ${dirArg} --apply`)
268
+ else out.push('Nada que hacer: el vault y Orquestra están igual.')
269
+ }
270
+ return out
271
+ }
272
+
273
+ /** Resumen final de lo que SÍ se hizo. */
274
+ export function renderSummary(c) {
275
+ const parts = []
276
+ const add = (n, singular, plural) => { if (n) parts.push(`${n} ${n === 1 ? singular : plural}`) }
277
+ add(c.pull, 'nota bajada', 'notas bajadas')
278
+ add(c.push, 'nota subida', 'notas subidas')
279
+ add(c.create, 'nota creada en Orquestra', 'notas creadas en Orquestra')
280
+ add(c.conflict, 'conflicto resuelto', 'conflictos resueltos')
281
+ add(c.up, 'archivo subido', 'archivos subidos')
282
+ add(c.down, 'archivo bajado', 'archivos bajados')
283
+ const lines = []
284
+ lines.push(c.failed ? `Listo con ${c.failed} error(es) -- vuelve a correr el comando para reintentar solo lo que falló.` : parts.length ? `✓ Listo: ${parts.join(', ')}.` : '✓ Listo: no había nada que cambiar.')
285
+ if (c.conflict) lines.push(' Revisa las copias *.conflict.md junto a cada nota en conflicto; cuando termines puedes borrarlas.')
286
+ lines.push(' El estado quedó en .orquestra-sync.json (no lo borres ni lo edites). La próxima vez solo se tocará lo que cambie.')
287
+ return lines
288
+ }
289
+
172
290
  // ── Comando ──────────────────────────────────────────────────────────────────
173
291
 
174
- const LABEL = { pull: '↓ bajar', push: '↑ subir', create: '↑ crear', conflict: '⚠ conflicto', orphan: '? sin entrada en Orquestra' }
292
+ const HELP = [
293
+ 'Uso: orquestra-mcp vault sync <carpeta-del-vault> [opciones]',
294
+ '',
295
+ 'Sincroniza la base de conocimiento de tu workspace con una carpeta de Obsidian, en los dos sentidos.',
296
+ 'Córrelo desde la carpeta de un proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).',
297
+ '',
298
+ 'Opciones:',
299
+ ' --apply escribe los cambios (la primera vez, sin esta opción solo se muestra el plan)',
300
+ ' --dry-run solo muestra el plan, aunque ya hayas sincronizado antes',
301
+ ' --no-attachments no sincroniza imágenes ni videos (solo el texto de las notas)',
302
+ ' --verbose también lista lo que ya está al día',
303
+ ' --help muestra esta ayuda',
304
+ '',
305
+ 'Qué sincroniza: notas .md (el nombre del archivo es el título, el frontmatter guarda orquestraId y tags) y las',
306
+ 'imágenes/videos (png, jpg, gif, webp, mp4, mov, webm) que las notas incrustan con ![[archivo]] o ![](ruta).',
307
+ 'Nunca borra nada. Si una nota cambia en los dos lados, gana la más reciente y la otra queda como .conflict.md.',
308
+ ]
309
+
310
+ /** Reparte `items` en `limit` tareas a la vez. */
311
+ async function mapLimit(items, limit, fn) {
312
+ const out = new Array(items.length)
313
+ let next = 0
314
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
315
+ while (next < items.length) { const i = next++; out[i] = await fn(items[i], i) }
316
+ }))
317
+ return out
318
+ }
175
319
 
176
320
  export async function runVaultSync(argv) {
177
321
  const dirArg = argv.find(a => !a.startsWith('--'))
178
- if (!dirArg) {
179
- console.log('Uso: orquestra-mcp vault sync <carpeta-del-vault> [--apply | --dry-run]\n' +
180
- 'Sincroniza la base de conocimiento del workspace con esa carpeta. Córrelo desde la carpeta de un\n' +
181
- 'proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).')
182
- return
183
- }
322
+ if (!dirArg || argv.includes('--help')) { console.log(HELP.join('\n')); return }
184
323
  const dir = path.resolve(dirArg)
185
324
  await fs.mkdir(dir, { recursive: true })
186
325
  const config = loadConfig()
@@ -193,19 +332,82 @@ export async function runVaultSync(argv) {
193
332
  }
194
333
  // Primera vez: solo muestra el plan, salvo --apply explícito.
195
334
  const apply = argv.includes('--apply') || (!!state && !argv.includes('--dry-run'))
335
+ const withAtt = !argv.includes('--no-attachments')
336
+ const verbose = argv.includes('--verbose')
196
337
 
338
+ console.log('Leyendo Orquestra y el vault...')
197
339
  const { items: remote } = await apiGet(config, '/v1/knowledge', { projectId, full: 'true' })
198
- const { notes: local, attachments } = await readLocal(dir)
340
+ const { notes: local } = await readLocal(dir)
199
341
  const plan = planSync({ remote, local, state })
342
+ const remoteIds = new Set(remote.map(r => r.id))
200
343
 
201
- console.log(`Vault: ${dir}\nOrquestra: ${remote.length} entradas · Vault: ${local.length} notas · ${plan.length} cambios${apply ? '' : ' (vista previa, no se escribe nada)'}`)
202
- for (const p of plan) console.log(` ${LABEL[p.op]} ${p.local?.file || p.remote?.title}${p.reason ? ` (${p.reason})` : ''}${p.op === 'conflict' ? ` -- gana ${p.winner === 'local' ? 'el vault' : 'Orquestra'}` : ''}`)
203
- if (attachments.length) console.log(` ${attachments.length} imagen(es)/adjunto(s) del vault no se sincronizan en esta versión (solo texto).`)
204
- if (!apply) {
205
- if (plan.length) console.log('\nPara aplicar: orquestra-mcp vault sync ' + dirArg + ' --apply')
206
- return
344
+ // ── Adjuntos: qué subir (según el vault) y qué bajar (según Orquestra) ──
345
+ const attState = structuredClone(state?.attachments || {})
346
+ const remoteCache = new Map()
347
+ const remoteFor = async id => {
348
+ if (!remoteCache.has(id)) remoteCache.set(id, await listRemoteAttachments(config, projectId, id).catch(() => new Map()))
349
+ return remoteCache.get(id)
350
+ }
351
+ const att = { enabled: withAtt, uploads: [], downloads: [], unchanged: 0, missing: [], unsupported: [], toolarge: [] }
352
+
353
+ async function planUploads(notes) {
354
+ // Las huérfanas (orquestraId que ya no existe) no se tocan; las nuevas (sin id) se suben al crearse.
355
+ const eligible = notes.filter(n => !n.id || remoteIds.has(n.id) || n.created)
356
+ const rels = new Set()
357
+ for (const n of eligible) for (const ref of embedRefs(n.body)) rels.add(ref)
358
+ const index = buildFileIndex(await scanVaultFiles(dir))
359
+ const referenced = []
360
+ for (const n of eligible) for (const ref of embedRefs(n.body)) { const rel = resolveRef(ref, n.file, index); if (rel) referenced.push(rel) }
361
+ const files = await fileInfoFor(dir, referenced)
362
+ const items = planAttachmentUploads({ notes: eligible, files, index, attState })
363
+ // Sin estado (se perdió el .orquestra-sync.json) pero ya subido: si Orquestra tiene el mismo archivo del mismo tamaño, se adopta en vez de duplicar.
364
+ await mapLimit(items.filter(i => i.status === 'upload' && i.note.id), 4, async it => {
365
+ const r = (await remoteFor(it.note.id)).get(it.rel)
366
+ if (r && r.fileSize === it.size) { it.status = 'adopt'; it.adoptId = r.id }
367
+ })
368
+ return items
369
+ }
370
+
371
+ const classify = items => {
372
+ att.uploads = []; att.unchanged = 0; att.missing = []; att.unsupported = []; att.toolarge = []
373
+ for (const it of items) {
374
+ const row = { rel: it.rel, ref: it.ref, title: it.note.title, size: it.size, status: it.status, maxMb: it.maxMb }
375
+ if (it.status === 'upload' || it.status === 'replace') att.uploads.push(row)
376
+ else if (it.status === 'unchanged' || it.status === 'adopt') att.unchanged++
377
+ else if (it.status === 'missing') att.missing.push(row)
378
+ else if (it.status === 'unsupported') att.unsupported.push(row)
379
+ else if (it.status === 'toolarge') att.toolarge.push(row)
380
+ }
381
+ }
382
+
383
+ async function planDownloads(entryIds, titleOf) {
384
+ const rows = []
385
+ await mapLimit(entryIds, 4, async id => {
386
+ for (const a of (await remoteFor(id)).values()) {
387
+ if (!isSafeRelPath(a.fileName)) continue
388
+ if (await fs.access(path.join(dir, a.fileName)).then(() => true, () => false)) continue
389
+ rows.push({ id, rel: a.fileName, title: titleOf(id), attachment: a })
390
+ }
391
+ })
392
+ return rows
393
+ }
394
+
395
+ let uploadItems = []
396
+ let pullIds = []
397
+ if (withAtt) {
398
+ uploadItems = await planUploads(local)
399
+ classify(uploadItems)
400
+ pullIds = [...new Set(plan.filter(p => p.op === 'pull' || (p.op === 'conflict' && p.winner === 'remote')).map(p => p.remote.id))]
401
+ const titles = new Map(remote.map(r => [r.id, r.title]))
402
+ att.downloads = (await planDownloads(pullIds, id => titles.get(id) || id)).map(({ rel, title }) => ({ rel, title }))
207
403
  }
208
404
 
405
+ for (const line of renderReport({ dir, dirArg, apply, firstRun: !state, lastSyncAt: state?.syncedAt, remoteCount: remote.length, localCount: local.length, plan, att, verbose })) console.log(line)
406
+ if (verbose && state) console.log(`\n(${local.length - plan.filter(p => p.local).length} notas sin cambios)`)
407
+ if (!apply) return
408
+
409
+ // ── Aplicar ─────────────────────────────────────────────────────────────
410
+ console.log('\nAplicando...')
209
411
  const entries = { ...(state?.entries || {}) }
210
412
  const taken = new Set(local.map(l => path.basename(l.file).toLowerCase()))
211
413
  const writeRemote = async (r, file, extra = []) => {
@@ -219,17 +421,22 @@ export async function runVaultSync(argv) {
219
421
  const saved = id ? await apiPatch(config, '/v1/knowledge', { id, ...body }) : await apiPost(config, '/v1/knowledge', body)
220
422
  // Reescribe el frontmatter con el id/updatedAt nuevos, sin tocar el texto.
221
423
  await writeRemote({ ...saved, title: l.title, body: l.body, tags: saved.tags || l.tags }, l.file, l.extra)
424
+ return saved.id || id
222
425
  }
223
426
  const saveConflictCopy = async (file, text) => {
224
427
  await fs.writeFile(path.join(dir, file.replace(/\.md$/, CONFLICT_SUFFIX)), text)
225
428
  }
226
429
 
227
- let failed = 0
430
+ const done = { pull: 0, push: 0, create: 0, conflict: 0, up: 0, down: 0, failed: 0 }
431
+ const createdIds = new Set()
432
+ let n = 0
228
433
  for (const p of plan) {
434
+ n++
435
+ if (p.op === 'orphan') continue
229
436
  try {
230
437
  if (p.op === 'pull') await writeRemote(p.remote, p.local?.file || entries[p.remote.id]?.file || uniqueFile(p.remote.title, taken), p.local?.extra)
231
438
  else if (p.op === 'push') await push(p.local, p.remote.id)
232
- else if (p.op === 'create') await push(p.local, null)
439
+ else if (p.op === 'create') createdIds.add(await push(p.local, null))
233
440
  else if (p.op === 'conflict') {
234
441
  if (p.winner === 'local') {
235
442
  await saveConflictCopy(p.local.file, serializeNote({ id: p.remote.id, title: p.remote.title, fileTitle: '', tags: p.remote.tags || [], body: p.remote.body || '' }))
@@ -239,11 +446,55 @@ export async function runVaultSync(argv) {
239
446
  await writeRemote(p.remote, p.local.file, p.local.extra)
240
447
  }
241
448
  }
449
+ done[p.op]++
450
+ console.log(` [${n}/${plan.length}] ✓ ${DONE_LABEL[p.op]} -- ${itemName(p)}`)
242
451
  } catch (err) {
243
- failed++
244
- console.error(` ✗ ${p.local?.file || p.remote?.title}: ${err.message}`)
452
+ done.failed++
453
+ console.error(` [${n}/${plan.length}] ✗ ${itemName(p)}: ${err.message}`)
454
+ }
455
+ }
456
+
457
+ if (withAtt) {
458
+ // Con las notas ya escritas (ids nuevos, cuerpos de Orquestra) se recalcula qué subir.
459
+ const { notes: after } = await readLocal(dir)
460
+ const items = await planUploads(after.map(x => ({ ...x, created: createdIds.has(x.id) })))
461
+ const todo = items.filter(i => ['upload', 'replace', 'adopt'].includes(i.status) && i.note.id)
462
+ let k = 0
463
+ for (const it of todo) {
464
+ k++
465
+ try {
466
+ const bucket = (attState[it.note.id] ||= {})
467
+ if (it.status === 'adopt') {
468
+ bucket[it.rel] = { attachmentId: it.adoptId, hash: it.hash, size: it.size }
469
+ continue
470
+ }
471
+ bucket[it.rel] = await uploadAttachment(config, projectId, it.note.id, dir, it)
472
+ done.up++
473
+ console.log(` [${k}/${todo.length}] ✓ ↑ ${it.rel}${it.status === 'replace' ? ' (reemplazó la versión anterior)' : ''}`)
474
+ } catch (err) {
475
+ done.failed++
476
+ console.error(` [${k}/${todo.length}] ✗ ${it.rel}: ${err.message}`)
477
+ }
478
+ }
479
+ const idsToFetch = [...new Set([...pullIds, ...createdIds])]
480
+ const titles = new Map(after.map(x => [x.id, x.title]))
481
+ const downloads = await planDownloads(idsToFetch, id => titles.get(id) || id)
482
+ for (const d of downloads) {
483
+ try {
484
+ if (await downloadAttachment(config, projectId, dir, d.attachment)) {
485
+ done.down++
486
+ const info = (await fileInfoFor(dir, [d.rel])).get(d.rel)
487
+ if (info) (attState[d.id] ||= {})[d.rel] = { attachmentId: d.attachment.id, hash: info.hash, size: info.size }
488
+ console.log(` ✓ ↓ ${d.rel}`)
489
+ }
490
+ } catch (err) {
491
+ done.failed++
492
+ console.error(` ✗ ${d.rel}: ${err.message}`)
493
+ }
245
494
  }
246
495
  }
247
- await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries }, null, 2))
248
- console.log(failed ? `\nListo con ${failed} error(es) -- vuelve a correrlo para reintentar.` : '\nListo.')
496
+
497
+ await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries, attachments: attState }, null, 2))
498
+ console.log('')
499
+ for (const line of renderSummary(done)) console.log(line)
249
500
  }