Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/i18n/README.ar.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ memory({ mode: "import", inputPath: "./memories.json" });
- تُكتب الذكريات التي ينشئها المكوّن الإضافي كـ `{id}.md` مع YAML frontmatter + المحتوى.
- تُستورد/تُفهرس الملاحظات الموجودة في المجلد (أي أسلوب صفحة Markdown)؛ وTurso مجرد فهرس بحث دلالي قابل لإعادة البناء.
- تُراقَب إضافات/تعديلات/حذف الملفات ويُعاد فهرستها؛ تعرض واجهة الويب صفحة شريط جانبي **Markdown** (الحالة، قائمة الملفات، إعادة الفهرسة) في هذا الوضع فقط.
- التبديل إلى Markdown يعيد بناء الفهرس من المجلد — تُسقَط صفوف قاعدة البيانات فقط بلا ملف مطابق. صدّر أولًا بـ `memory export` إن احتجت الإبقاء على ذكريات قاعدة البيانات القديمة.
- التبديل إلى Markdown يعيد بناء الفهرس من المجلد. لا تُحذف السجلات الموجودة في قاعدة البيانات فقط والتي لا يقابلها ملف إلا بعد فحص كامل لخزنة قابلة للقراءة وغير فارغة. صدّر أولًا باستخدام `memory export` إذا أردت الاحتفاظ بذكريات قاعدة البيانات القديمة.
- تتخطى المطابقة الحذف وتصدر تحذيرًا إذا كانت الخزنة مفقودة أو غير قابلة للقراءة أو جرى فحصها أو معالجتها جزئيًا فقط أو لا تحتوي على ملفات Markdown؛ وتبقى الذكريات المفهرسة الموجودة محفوظة. استعد الخزنة أو أصلحها، ثم أعد الفهرسة. لإزالة الذاكرة المفهرسة المرتبطة بآخر ملف عمدًا، احذف تلك الذاكرة صراحةً من خلال الأداة أو واجهة الويب.
- تظل الذكريات المفهرسة التي لم تعد ملفاتها موجودة عرضة للحذف الدائم عندما تكون الخزنة سليمة وغير فارغة. صدّر البيانات أو أنشئ نسخة احتياطية أولًا: هذه الإجراءات الوقائية ليست بديلًا عن النسخ الاحتياطي ولا تكشف المزامنة غير المكتملة التي تبدو كخزنة سليمة وغير فارغة.

### التقاط رسائل الدردشة وحقنها (`chatMessage`)

Expand Down
4 changes: 3 additions & 1 deletion .github/i18n/README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ Wenn Markdown-Modus aktiv ist:
- Vom Plugin erzeugte Memories werden als `{id}.md` mit YAML-Frontmatter + Body geschrieben.
- Vorhandene Notizen im Ordner (beliebiger Markdown-Seitenstil) werden importiert/indexiert; Turso ist nur ein neu aufbaubarer Semantic-Search-Index.
- Datei-Adds/Edits/Deletes werden beobachtet und neu indexiert; die Web-UI zeigt nur in diesem Modus eine **Markdown**-Sidebar-Seite (Status, Dateiliste, Reindex).
- Der Wechsel zu Markdown baut den Index aus dem Ordner neu — DB-only-Zeilen ohne passende Datei entfallen. Zuerst mit `memory export` exportieren, wenn du alte Datenbank-Memories behalten willst.
- Der Wechsel zu Markdown baut den Index aus dem Ordner neu auf. Reine Datenbankeinträge ohne passende Datei werden nur nach einem lesbaren, vollständigen Scan eines nicht leeren Vaults entfernt. Exportiere zuerst mit `memory export`, wenn du alte Datenbank-Memories behalten möchtest.
- Der Abgleich überspringt das Löschen und gibt eine Warnung aus, wenn der Vault fehlt, nicht lesbar ist, nur teilweise gescannt oder verarbeitet wurde oder keine Markdown-Dateien enthält; bereits indexierte Memories bleiben erhalten. Stelle den Vault wieder her oder behebe das Problem und indexiere ihn erneut. Um die indexierte Memory der letzten Datei absichtlich zu entfernen, lösche diese Memory ausdrücklich über das Tool oder die Web-UI.
- Bei einem funktionierenden, nicht leeren Vault werden indexierte Memories ohne zugehörige Datei weiterhin endgültig gelöscht. Exportiere die Daten oder erstelle vorher ein Backup: Diese Schutzmaßnahmen ersetzen kein Backup und erkennen keine unvollständige Synchronisierung, die wie ein funktionierender, nicht leerer Vault aussieht.

### Erfassung und Injection von Chat-Nachrichten (`chatMessage`)

Expand Down
4 changes: 3 additions & 1 deletion .github/i18n/README.nl.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ Wanneer Markdown-modus aan staat:
- Door de plugin gemaakte herinneringen worden als `{id}.md` met YAML-frontmatter + body geschreven.
- Bestaande notities in de map (elke Markdown-paginastijl) worden geïmporteerd/geïndexeerd; Turso is alleen een herbouwbare semantic-search-index.
- Bestandsadds/bewerkingen/verwijderingen worden bewaakt en opnieuw geïndexeerd; de web-UI toont alleen in deze modus een **Markdown**-sidebarpagina (status, bestandenlijst, reindex).
- Overschakelen naar Markdown herbouwt de index vanuit de map — DB-only-rijen zonder bijpassend bestand verdwijnen. Exporteer eerst met `memory export` als je oude databaseherinneringen wilt bewaren.
- Overschakelen naar Markdown herbouwt de index vanuit de map. Rijen die alleen in de database staan en geen bijpassend bestand hebben, worden alleen verwijderd na een leesbare, volledige scan van een niet-lege vault. Exporteer eerst met `memory export` als je oude databaseherinneringen wilt bewaren.
- Bij het synchroniseren wordt verwijdering overgeslagen en verschijnt een waarschuwing als de vault ontbreekt, onleesbaar is, slechts gedeeltelijk is gescand of verwerkt, of geen Markdown-bestanden bevat; bestaande geïndexeerde herinneringen blijven behouden. Herstel de vault of los het probleem op en indexeer daarna opnieuw. Wil je de geïndexeerde herinnering van het laatste bestand bewust verwijderen, verwijder die herinnering dan expliciet via de tool of web-UI.
- Bij een gezonde, niet-lege vault worden geïndexeerde herinneringen waarvan de bestanden ontbreken nog steeds permanent verwijderd. Exporteer of maak eerst een back-up: deze beveiligingen vervangen geen back-up en herkennen geen onvolledige synchronisatie die eruitziet als een gezonde, niet-lege vault.

### Chatberichtvastlegging en -injectie (`chatMessage`)

Expand Down
4 changes: 3 additions & 1 deletion .github/i18n/README.tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ Markdown modu açıkken:
- Eklentinin oluşturduğu bellekler YAML frontmatter + gövde ile `{id}.md` olarak yazılır.
- Klasördeki mevcut notlar (herhangi bir Markdown sayfa stili) içe aktarılır/indekslenir; Turso yalnızca yeniden oluşturulabilir bir anlamsal arama indeksidir.
- Dosya ekleme/düzenleme/silme izlenir ve yeniden indekslenir; Web arayüzü yalnızca bu modda bir **Markdown** kenar çubuğu sayfası gösterir (durum, dosya listesi, yeniden indeks).
- Markdown’a geçiş indeksi klasörden yeniden oluşturur — eşleşen dosyası olmayan yalnızca-DB satırları düşer. Eski veritabanı belleklerini saklamak istiyorsanız önce `memory export` ile dışa aktarın.
- Markdown’a geçiş indeksi klasörden yeniden oluşturur. Eşleşen dosyası olmayan, yalnızca veritabanında bulunan kayıtlar ancak okunabilir ve boş olmayan bir vault tamamen tarandıktan sonra silinir. Eski veritabanı belleklerini saklamak istiyorsanız önce `memory export` ile dışa aktarın.
- Vault yoksa, okunamıyorsa, yalnızca kısmen taranmış veya işlenmişse ya da hiç Markdown dosyası içermiyorsa uzlaştırma silme işlemini atlar ve uyarı verir; mevcut indekslenmiş bellekler korunur. Vault'u geri yükleyin veya sorunu giderin, ardından yeniden indeksleyin. Son dosyanın indekslenmiş belleğini bilerek kaldırmak için bu belleği araç veya Web UI üzerinden açıkça silin.
- Sağlıklı ve boş olmayan bir vault'ta, dosyaları artık bulunmayan indekslenmiş bellekler yine kalıcı olarak silinir. Önce dışa aktarın veya yedek alın: bu önlemler yedeklemenin yerini tutmaz ve sağlıklı, boş olmayan bir vault gibi görünen eksik senkronizasyonu tespit edemez.

### Sohbet mesajı yakalama ve enjekte etme (`chatMessage`)

Expand Down
4 changes: 3 additions & 1 deletion .github/i18n/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@ memory({ mode: "import", inputPath: "./memories.json" });
- 插件创建的记忆以 `{id}.md` 写入,含 YAML frontmatter + 正文。
- 文件夹中已有笔记(任意 Markdown 页面风格)会被导入/索引;Turso 仅作为可重建的语义搜索索引。
- 监视文件新增/编辑/删除并重新索引;Web UI 仅在此模式下显示 **Markdown** 侧边栏页(状态、文件列表、重新索引)。
- 切换到 Markdown 会从文件夹重建索引 — 没有对应文件的仅数据库行会被丢弃。若需保留旧数据库记忆,请先用 `memory export` 导出。
- 切换到 Markdown 会从文件夹重建索引。只有在对可读取、非空的 vault 完成完整扫描后,才会删除没有对应文件的纯数据库记录。如需保留旧数据库记忆,请先用 `memory export` 导出。
- 如果 vault 缺失、不可读取、仅完成部分扫描或处理,或不包含任何 Markdown 文件,对账会跳过删除并发出警告,保留已有的索引记忆。恢复或修复 vault 后,请重新索引。如果确实要移除最后一个文件对应的索引记忆,请通过工具或 Web UI 明确删除该记忆。
- 对于正常且非空的 vault,对应文件已不存在的索引记忆仍会被永久删除。请先导出或备份:这些保护措施不能替代备份,也无法识别看似正常且非空、实则尚未完成同步的 vault。

### 聊天消息捕获与注入(`chatMessage`)

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,9 @@ When Markdown mode is on:
- Plugin-created memories are written as `{id}.md` with YAML frontmatter + body.
- Existing notes in the folder (any Markdown page style) are imported/indexed; Turso is only a rebuildable semantic-search index.
- File adds/edits/deletes are watched and re-indexed; the Web UI shows a **Markdown** sidebar page (status, file list, reindex) only in this mode.
- Switching to Markdown rebuilds the index from the folder — DB-only rows without a matching file are dropped. Export with `memory export` first if you need to keep old database memories.
- Switching to Markdown rebuilds the index from the folder. DB-only rows without a matching file are dropped only after a readable, complete scan of a nonempty vault. Export with `memory export` first if you need to keep old database memories.
- Reconciliation skips deletion and warns when the vault is missing, unreadable, only partially scanned or processed, or contains no Markdown files; existing indexed memories are retained. Restore or fix the vault, then reindex. To intentionally remove the final file's indexed memory, explicitly delete that memory through the tool or Web UI.
- A healthy, nonempty vault still permits permanent deletion of indexed memories whose files are absent. Export or back up first: these safeguards are not a backup and cannot detect an incomplete sync that appears to be a healthy, nonempty vault.

### Chat message capture and injection (`chatMessage`)

Expand Down
90 changes: 64 additions & 26 deletions src/services/markdown/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ import {
parseMemoryMarkdown,
titleFromRelPath,
} from "./document.js";
import { listMarkdownFiles, readVaultFile, type VaultFileInfo } from "./vault.js";
import {
listMarkdownFiles,
readVaultFile,
scanMarkdownVault,
type VaultFileInfo,
} from "./vault.js";

export interface ReconcileResult {
scanned: number;
upserted: number;
unchanged: number;
deleted: number;
/** Missing files are not evidence of deletion when the inventory is unsafe. */
deletionSkipped?: "empty-vault" | "incomplete-scan";
errors: Array<{ path: string; error: string }>;
finishedAt: number;
}
Expand Down Expand Up @@ -83,9 +90,11 @@ async function resolveContainerTag(fmContainerTag: string | undefined): Promise<
return getUserTagInfo().tag;
}

async function upsertFromFile(file: VaultFileInfo): Promise<"upserted" | "unchanged"> {
const raw = readVaultFile(CONFIG.markdownPath!, file.relPath);
const parsed = parseMemoryMarkdown(raw);
async function upsertFromFile(
file: VaultFileInfo,
raw: string,
parsed = parseMemoryMarkdown(raw)
): Promise<"upserted" | "unchanged"> {
const fm = parsed.frontmatter;

const id =
Expand Down Expand Up @@ -208,6 +217,11 @@ async function collectIndexedMarkdownIds(): Promise<
}

export async function reconcileMarkdownVault(): Promise<ReconcileResult> {
return reconcileVault();
}

/** A watcher deletion validates the whole inventory but only removes its own path. */
async function reconcileVault(deletedRelPath?: string): Promise<ReconcileResult> {
const vaultRoot = CONFIG.markdownPath;
if (!vaultRoot) {
throw new Error("markdownPath is not configured");
Expand All @@ -216,14 +230,14 @@ export async function reconcileMarkdownVault(): Promise<ReconcileResult> {
await ensureTursoReady();
await embeddingService.warmup();

const files = listMarkdownFiles(vaultRoot);
const { files, errors } = scanMarkdownVault(vaultRoot);
const seenIds = new Set<string>();
const result: ReconcileResult = {
scanned: files.length,
upserted: 0,
unchanged: 0,
deleted: 0,
errors: [],
errors,
finishedAt: Date.now(),
};

Expand All @@ -236,7 +250,9 @@ export async function reconcileMarkdownVault(): Promise<ReconcileResult> {
? parsed.frontmatter.id.trim()
: markdownIdFromRelPath(file.relPath);
seenIds.add(id);
const outcome = await upsertFromFile(file);
// Use the same snapshot for identity and content. A second read could see
// a different id after an editor/sync write and delete the new row below.
const outcome = await upsertFromFile(file, raw, parsed);
if (outcome === "upserted") result.upserted++;
else result.unchanged++;
} catch (error) {
Expand All @@ -252,16 +268,40 @@ export async function reconcileMarkdownVault(): Promise<ReconcileResult> {
}

const indexed = await collectIndexedMarkdownIds();
for (const [id] of indexed) {
if (seenIds.has(id)) continue;
try {
const deleted = await deleteMemoryById(id);
if (deleted) result.deleted++;
} catch (error) {
result.errors.push({
path: id,
error: error instanceof Error ? error.message : String(error),
});
if (result.errors.length > 0) {
result.deletionSkipped = "incomplete-scan";
} else if (files.length === 0 && indexed.size > 0) {
// An intentionally emptied vault cannot be distinguished from a missing
// mount or incomplete sync. Explicit memory deletion remains available.
result.deletionSkipped = "empty-vault";
}

if (result.deletionSkipped) {
log("markdown reconcile warning: deletion skipped; existing index retained", {
reason: result.deletionSkipped,
path: vaultRoot,
indexed: indexed.size,
errors: result.errors,
});
} else {
for (const [id, info] of indexed) {
if (seenIds.has(id)) continue;
if (
deletedRelPath !== undefined &&
info.relPath !== deletedRelPath &&
id !== markdownIdFromRelPath(deletedRelPath)
) {
continue;
}
try {
const deleted = await deleteMemoryById(id);
if (deleted) result.deleted++;
} catch (error) {
result.errors.push({
path: id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}

Expand All @@ -271,6 +311,7 @@ export async function reconcileMarkdownVault(): Promise<ReconcileResult> {
upserted: result.upserted,
unchanged: result.unchanged,
deleted: result.deleted,
deletionSkipped: result.deletionSkipped,
errors: result.errors.length,
});
return result;
Expand Down Expand Up @@ -328,17 +369,14 @@ export async function reindexVaultFile(relPath: string): Promise<void> {
if (!vaultRoot) throw new Error("markdownPath is not configured");
await ensureTursoReady();
await embeddingService.warmup();
const files = listMarkdownFiles(vaultRoot);
const { files } = scanMarkdownVault(vaultRoot);
const file = files.find((f) => f.relPath === relPath);
if (!file) {
// File gone — drop any id derived from path or previously stored for this path.
const indexed = await collectIndexedMarkdownIds();
for (const [id, info] of indexed) {
if (info.relPath === relPath || id === markdownIdFromRelPath(relPath)) {
await deleteMemoryById(id);
}
}
// Watcher events also arrive when the vault disappears or cannot be read.
// Require the same complete, nonempty inventory as a full reconciliation.
// This also preserves an explicit id when a file was renamed in the vault.
await reconcileVault(relPath);
return;
}
await upsertFromFile(file);
await upsertFromFile(file, readVaultFile(vaultRoot, file.relPath));
}
32 changes: 26 additions & 6 deletions src/services/markdown/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export interface VaultFileInfo {
size: number;
}

export interface VaultScanResult {
files: VaultFileInfo[];
/** A partial inventory must never be used to infer that memories were deleted. */
errors: Array<{ path: string; error: string }>;
}

function normalizeRel(relPath: string): string {
return relPath.replace(/\\/g, "/").replace(/^\/+/, "");
}
Expand Down Expand Up @@ -66,17 +72,24 @@ function shouldSkipDir(name: string): boolean {
return false;
}

export function listMarkdownFiles(vaultRoot: string): VaultFileInfo[] {
export function scanMarkdownVault(vaultRoot: string): VaultScanResult {
const root = resolve(vaultRoot);
if (!existsSync(root)) return [];

const out: VaultFileInfo[] = [];
const errors: VaultScanResult["errors"] = [];

function recordError(absPath: string, error: unknown): void {
errors.push({
path: normalizeRel(relative(root, absPath)) || ".",
error: error instanceof Error ? error.message : String(error),
});
}

function walk(dir: string): void {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
} catch (error) {
recordError(dir, error);
return;
}
for (const entry of entries) {
Expand All @@ -91,7 +104,9 @@ export function listMarkdownFiles(vaultRoot: string): VaultFileInfo[] {
let st;
try {
st = statSync(abs);
} catch {
if (!st.isFile()) throw new Error("Markdown file is no longer a regular file");
} catch (error) {
recordError(abs, error);
continue;
}
const rel = normalizeRel(relative(root, abs));
Expand All @@ -106,7 +121,12 @@ export function listMarkdownFiles(vaultRoot: string): VaultFileInfo[] {

walk(root);
out.sort((a, b) => a.relPath.localeCompare(b.relPath));
return out;
return { files: out, errors };
}

/** Best-effort listing for display; reconciliation must use the scan's errors too. */
export function listMarkdownFiles(vaultRoot: string): VaultFileInfo[] {
return scanMarkdownVault(vaultRoot).files;
}

export function readVaultFile(vaultRoot: string, relPath: string): string {
Expand Down
Loading
Loading