From d97d6e72c4f26b44cfbbbcc0fecf65bf6facc1b2 Mon Sep 17 00:00:00 2001 From: Yan Date: Wed, 20 May 2026 15:17:01 +0800 Subject: [PATCH] feat(core): unified LLM client, knowledge index, execution plan infra --- .DS_Store | Bin 8196 -> 0 bytes Readme.md | 122 -------- main.js | 322 -------------------- src/.DS_Store | Bin 6148 -> 0 bytes src/core/execution/PlanBuilder.ts | 132 ++++++++ src/core/execution/PlanExecutor.ts | 219 +++++++++++++ src/core/execution/index.ts | 16 + src/core/execution/types.ts | 113 +++++++ src/core/knowledge/KnowledgeIndexService.ts | 293 ++++++++++++++++++ src/core/knowledge/index.ts | 2 + src/core/llm/JsonExtractor.ts | 37 +++ src/core/llm/LLMClient.ts | 87 ++++++ src/core/llm/index.ts | 3 + src/core/schema.ts | 20 ++ src/features/.DS_Store | Bin 8196 -> 0 bytes 15 files changed, 922 insertions(+), 444 deletions(-) delete mode 100644 .DS_Store delete mode 100644 Readme.md delete mode 100644 main.js delete mode 100644 src/.DS_Store create mode 100644 src/core/execution/PlanBuilder.ts create mode 100644 src/core/execution/PlanExecutor.ts create mode 100644 src/core/execution/index.ts create mode 100644 src/core/execution/types.ts create mode 100644 src/core/knowledge/KnowledgeIndexService.ts create mode 100644 src/core/knowledge/index.ts create mode 100644 src/core/llm/JsonExtractor.ts create mode 100644 src/core/llm/LLMClient.ts create mode 100644 src/core/llm/index.ts create mode 100644 src/core/schema.ts delete mode 100644 src/features/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 6bfd6357b4325503adbda57886ff8eb4fd8bdd27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMzi-n(6n;)jMQWu5_5{N1)222P$su2u;LL5h_RmT^_4iSnXd0_$m z0|a7+1tu6+SeOB+68ss=47~f1u^s4}60I;x7 zEG=VofKfj!8Ow>DHADmRA?#M&HtF>0eogtvlcw}xmVX( z0j7de97>ipWz9al|lqI`-Ql z&Jmb$2!TMyhFGIEnvGK}l3 z!999)n~>0(wp-qvpfhTgPTwF=(h8!^fF%Te2Se`P4I-b6>SP%CG0Szi!zxs976Qyi3ic8E}P{p-n4r92LlO(q(%7zq$DR|8cxE%~>m;6*!^-hS_i%bqt>VnB1R7pYk@Ahghi5 oxRfJH!AkidfY-A>46$uvD9v#MrW~;cO}_|WGU!Sx@K+W11G@(fmjD0& diff --git a/Readme.md b/Readme.md deleted file mode 100644 index dc2dcc1..0000000 --- a/Readme.md +++ /dev/null @@ -1,122 +0,0 @@ -# IStart-Note-AI - -AI-powered knowledge management plugin for Obsidian. One unified AI assistant that helps you build structured notes, read books effectively, and visualize knowledge — all through natural language. - ---- - -## Features - -### AI Assistant (unified entry) - -One input handles everything. Select text or place your cursor, then tell the AI what you want: - -- **Expand** — select text, ask to expand or rewrite -- **Explain** — select a term, ask to explain -- **Diagrams** — describe what you want (flowchart, sequence, state, class, ER, Gantt) -- **Formulas** — describe a math expression, get LaTeX -- **Complete** — fill empty sections based on context -- **Continue** — write more from cursor position -- **Summarize** — summarize the current document -- **Beautify** — restructure existing content with callouts, links, and visual breaks -- **Anything else** — just describe it in natural language - -Quick tags for common actions: `[扩写]` `[解释]` `[画图]` `[补全]` `[续写]` `[总结]` `[公式]` `[时序图]` - -### Structured Output - -AI generates content in professional knowledge-base style: - -- Short paragraphs with visual breaks -- Obsidian Callouts (`> [!summary]`, `> [!warning]`, `> [!tip]`) -- Automatic Mermaid diagrams where appropriate -- Auto-linked `[[concepts]]` to existing pages -- Configurable output style (technical, minimal, academic, etc.) - -### Reading Projects - -Turn any book into a structured study plan: - -1. Enter book title (optionally paste table of contents) -2. AI generates reading roadmap, chapter relationships, and pre-reading questions -3. Record notes as you read -4. Generate chapter summaries and take Feynman tests -5. Supports resume if generation is interrupted - -### Knowledge Organization - -- Concepts auto-organized into domain subdirectories after completion -- Domain MOC index pages with Mermaid overview graphs -- Relationship diagrams in concept pages -- Question evolution graphs in question index - -### Baidu Cloud Sync - -- Incremental backup / bidirectional sync / force overwrite -- Plugin and Obsidian config backup (toolbar, hotkeys, appearance) -- Auto backup after note generation - ---- - -## Requirements - -- Obsidian 1.7.2 or later -- A [DeepSeek API key](https://platform.deepseek.com) - ---- - -## Installation - -### From community plugins (recommended) - -1. Settings → Community plugins → Browse -2. Search **IStart-Note-AI** -3. Install → Enable - -### Manual - -1. Build: `npm install && npm run build` -2. Copy `dist/` contents to `.obsidian/plugins/istart-note-ai/` -3. Enable in Settings → Community plugins - ---- - -## Configuration - -Settings → IStart-Note-AI: - -| Setting | Description | Default | -|---------|-------------|---------| -| API Key | DeepSeek API key | — | -| Base URL | API endpoint | `https://api.deepseek.com` | -| Model | `deepseek-v4-flash` or `deepseek-v4-pro` | `deepseek-v4-flash` | -| Output style | Knowledge-base, technical, minimal, product, academic, story, dashboard | Knowledge-base | -| Q&A folder | Where Q&A notes are saved | `Knowledge/Q&A` | -| Concepts folder | Where concept pages are saved | `Knowledge/Concepts` | - ---- - -## Usage - -### Desktop - -- **🧠 Ribbon icon** → Opens command panel -- **Right-click in editor** → "IStart-Note-AI: AI 助手" -- **Right-click file in sidebar** → "IStart-Note-AI: AI 助手" - -### Mobile - -- **🧠 Ribbon icon** → Opens command panel -- Add `AI 助手` to mobile toolbar for one-tap access - -### Workflow - -1. Select text (optional) -2. Click 🧠 or right-click → AI 助手 -3. Type your request (or tap a quick tag, or leave blank for auto-detect) -4. Preview result → Confirm - ---- - -## License - -MIT. See [LICENSE](LICENSE). diff --git a/main.js b/main.js deleted file mode 100644 index 94cd62c..0000000 --- a/main.js +++ /dev/null @@ -1,322 +0,0 @@ -/* -THIS IS A GENERATED/BUNDLED FILE BY ESBUILD -if you want to view the source, please visit the github repository of this plugin -*/ - -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); - } - return to; -}; -var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); - -// src/main.ts -var main_exports = {}; -__export(main_exports, { - default: () => DeepSeekPlugin -}); -module.exports = __toCommonJS(main_exports); -var import_obsidian4 = require("obsidian"); - -// src/types.ts -var DEFAULT_SETTINGS = { - apiKey: "", - baseUrl: "https://api.deepseek.com", - model: "deepseek-chat", - savePath: "Knowledge/Q&A", - autoOpenGraph: false -}; - -// src/DeepSeekClient.ts -var SYSTEM_PROMPT = `\u4F60\u662F\u4E00\u4E2A\u77E5\u8BC6\u56FE\u8C31\u6784\u5EFA\u52A9\u624B\u3002\u7528\u6237\u4F1A\u5411\u4F60\u63D0\u95EE\uFF0C\u4F60\u9700\u8981\uFF1A -1. \u7ED9\u51FA\u6E05\u6670\u7684\u56DE\u7B54 -2. \u63D0\u53D6\u5173\u952E\u6982\u5FF5\uFF083-7\u4E2A\uFF09 -3. \u8BC6\u522B\u6982\u5FF5\u95F4\u7684\u5173\u7CFB\uFF08\u9650\u4E8E\uFF1A\u5F71\u54CD\u3001\u5C5E\u4E8E\u3001\u5BFC\u81F4\u3001\u4F9D\u8D56\u3001\u5BF9\u7ACB\uFF09 -4. \u751F\u6210\u76F8\u5173\u6807\u7B7E\uFF082-5\u4E2A\uFF09 - -\u4E25\u683C\u6309\u7167\u4EE5\u4E0B JSON \u683C\u5F0F\u8FD4\u56DE\uFF0C\u4E0D\u8981\u6709\u4EFB\u4F55\u5176\u4ED6\u5185\u5BB9\uFF1A -{ - "answer": "\u8BE6\u7EC6\u56DE\u7B54", - "concepts": ["\u6982\u5FF5A", "\u6982\u5FF5B"], - "relations": [ - { "from": "\u6982\u5FF5A", "relation": "\u5F71\u54CD", "to": "\u6982\u5FF5B" } - ], - "tags": ["\u6807\u7B7E1", "\u6807\u7B7E2"] -}`; -var DeepSeekClient = class { - constructor(settings) { - this.settings = settings; - } - async ask(question) { - var _a, _b, _c; - if (!this.settings.apiKey) { - throw new Error("\u8BF7\u5148\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u914D\u7F6E DeepSeek API Key"); - } - const response = await fetch(`${this.settings.baseUrl}/v1/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.settings.apiKey}` - }, - body: JSON.stringify({ - model: this.settings.model, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { role: "user", content: question } - ], - temperature: 0.7 - }) - }); - if (!response.ok) { - const err = await response.text(); - throw new Error(`DeepSeek API \u9519\u8BEF: ${response.status} - ${err}`); - } - const data = await response.json(); - const content = (_c = (_b = (_a = data.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content; - if (!content) { - throw new Error("DeepSeek \u8FD4\u56DE\u5185\u5BB9\u4E3A\u7A7A"); - } - return this.parseResponse(content); - } - parseResponse(content) { - const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/(\{[\s\S]*\})/); - const jsonStr = jsonMatch ? jsonMatch[1] : content; - try { - const parsed = JSON.parse(jsonStr.trim()); - return { - answer: parsed.answer || "", - concepts: Array.isArray(parsed.concepts) ? parsed.concepts : [], - relations: Array.isArray(parsed.relations) ? parsed.relations : [], - tags: Array.isArray(parsed.tags) ? parsed.tags : [] - }; - } catch (e) { - return { - answer: content, - concepts: [], - relations: [], - tags: [] - }; - } - } -}; - -// src/VaultWriter.ts -var import_obsidian = require("obsidian"); -var VaultWriter = class { - constructor(app, settings) { - this.app = app; - this.settings = settings; - } - async writeQANote(question, response) { - const date = new Date().toISOString().slice(0, 10); - const safeTitle = this.sanitizeFilename(question).slice(0, 50); - const filename = `${date}-${safeTitle}.md`; - const folderPath = (0, import_obsidian.normalizePath)(this.settings.savePath); - const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${filename}`); - await this.ensureFolder(folderPath); - const content = this.buildNoteContent(question, response); - const file = await this.app.vault.create(filePath, content); - for (const concept of response.concepts) { - await this.ensureConceptNote(concept); - } - return file; - } - buildNoteContent(question, response) { - const conceptLinks = response.concepts.map((c) => `- [[${c}]]`).join("\n"); - const relationLines = response.relations.map((r) => `- [[${r.from}]] -${r.relation}-> [[${r.to}]]`).join("\n"); - const tagLine = response.tags.map((t) => `#${t.replace(/\s+/g, "_")}`).join(" "); - return `# ${question} - -## Question -${question} - -## Answer -${response.answer} - -## Concepts -${conceptLinks || "- \u6682\u65E0"} - -## Relations -${relationLines || "- \u6682\u65E0"} - -## Tags -${tagLine || "\u6682\u65E0\u6807\u7B7E"} -`; - } - async ensureConceptNote(concept) { - const folderPath = (0, import_obsidian.normalizePath)("Knowledge/Concepts"); - const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${concept}.md`); - await this.ensureFolder(folderPath); - const exists = this.app.vault.getAbstractFileByPath(filePath); - if (!exists) { - await this.app.vault.create( - filePath, - `# ${concept} - -## \u5B9A\u4E49 - -## \u5173\u8054 - -## \u6765\u6E90 -` - ); - } - } - async ensureFolder(path) { - const exists = this.app.vault.getAbstractFileByPath(path); - if (!exists) { - await this.app.vault.createFolder(path); - } - } - sanitizeFilename(name) { - return name.replace(/[\\/:*?"<>|#\[\]]/g, "-").trim(); - } -}; - -// src/QuestionModal.ts -var import_obsidian2 = require("obsidian"); -var QuestionModal = class extends import_obsidian2.Modal { - constructor(app, onSubmit) { - super(app); - this.question = ""; - this.onSubmit = onSubmit; - } - onOpen() { - const { contentEl } = this; - contentEl.createEl("h2", { text: "\u5411 DeepSeek \u63D0\u95EE" }); - const textArea = contentEl.createEl("textarea", { - attr: { - placeholder: "\u8F93\u5165\u4F60\u7684\u95EE\u9898...", - rows: "4", - style: "width:100%; resize:vertical; padding:8px; font-size:14px;" - } - }); - textArea.addEventListener("input", () => { - this.question = textArea.value; - }); - textArea.addEventListener("keydown", (e) => { - if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { - this.submit(); - } - }); - new import_obsidian2.Setting(contentEl).addButton( - (btn) => btn.setButtonText("\u63D0\u95EE (Ctrl+Enter)").setCta().onClick(() => this.submit()) - ).addButton( - (btn) => btn.setButtonText("\u53D6\u6D88").onClick(() => this.close()) - ); - setTimeout(() => textArea.focus(), 50); - } - submit() { - const q = this.question.trim(); - if (!q) - return; - this.close(); - this.onSubmit(q); - } - onClose() { - this.contentEl.empty(); - } -}; - -// src/SettingsTab.ts -var import_obsidian3 = require("obsidian"); -var DeepSeekSettingsTab = class extends import_obsidian3.PluginSettingTab { - constructor(app, plugin) { - super(app, plugin); - this.plugin = plugin; - } - display() { - const { containerEl } = this; - containerEl.empty(); - containerEl.createEl("h2", { text: "DeepSeek Knowledge Graph \u8BBE\u7F6E" }); - new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("DeepSeek API Key\uFF08\u5728 platform.deepseek.com \u83B7\u53D6\uFF09").addText( - (text) => text.setPlaceholder("sk-...").setValue(this.plugin.settings.apiKey).onChange(async (value) => { - this.plugin.settings.apiKey = value.trim(); - await this.plugin.saveSettings(); - }) - ); - new import_obsidian3.Setting(containerEl).setName("Base URL").setDesc("API \u5730\u5740\uFF0C\u9ED8\u8BA4 https://api.deepseek.com").addText( - (text) => text.setPlaceholder("https://api.deepseek.com").setValue(this.plugin.settings.baseUrl).onChange(async (value) => { - this.plugin.settings.baseUrl = value.trim() || "https://api.deepseek.com"; - await this.plugin.saveSettings(); - }) - ); - new import_obsidian3.Setting(containerEl).setName("\u6A21\u578B").setDesc("\u9009\u62E9\u4F7F\u7528\u7684 DeepSeek \u6A21\u578B").addDropdown( - (drop) => drop.addOption("deepseek-chat", "deepseek-chat\uFF08\u63A8\u8350\uFF09").addOption("deepseek-reasoner", "deepseek-reasoner\uFF08\u6DF1\u5EA6\u63A8\u7406\uFF09").setValue(this.plugin.settings.model).onChange(async (value) => { - this.plugin.settings.model = value; - await this.plugin.saveSettings(); - }) - ); - new import_obsidian3.Setting(containerEl).setName("\u7B14\u8BB0\u4FDD\u5B58\u8DEF\u5F84").setDesc("Q&A \u7B14\u8BB0\u5B58\u50A8\u76EE\u5F55\uFF08\u76F8\u5BF9\u4E8E Vault \u6839\u76EE\u5F55\uFF09").addText( - (text) => text.setPlaceholder("Knowledge/Q&A").setValue(this.plugin.settings.savePath).onChange(async (value) => { - this.plugin.settings.savePath = value.trim() || "Knowledge/Q&A"; - await this.plugin.saveSettings(); - }) - ); - new import_obsidian3.Setting(containerEl).setName("\u81EA\u52A8\u6253\u5F00 Graph View").setDesc("\u751F\u6210\u7B14\u8BB0\u540E\u81EA\u52A8\u6253\u5F00\u56FE\u8C31\u89C6\u56FE").addToggle( - (toggle) => toggle.setValue(this.plugin.settings.autoOpenGraph).onChange(async (value) => { - this.plugin.settings.autoOpenGraph = value; - await this.plugin.saveSettings(); - }) - ); - } -}; - -// src/main.ts -var DeepSeekPlugin = class extends import_obsidian4.Plugin { - async onload() { - await this.loadSettings(); - this.addRibbonIcon("brain", "DeepSeek \u63D0\u95EE", () => { - this.openQuestionModal(); - }); - this.addCommand({ - id: "ask-deepseek", - name: "\u5411 DeepSeek \u63D0\u95EE\u5E76\u751F\u6210\u77E5\u8BC6\u7B14\u8BB0", - hotkeys: [{ modifiers: ["Mod", "Shift"], key: "d" }], - callback: () => this.openQuestionModal() - }); - this.addSettingTab(new DeepSeekSettingsTab(this.app, this)); - } - openQuestionModal() { - new QuestionModal(this.app, (question) => { - this.processQuestion(question); - }).open(); - } - async processQuestion(question) { - const notice = new import_obsidian4.Notice("\u23F3 DeepSeek \u601D\u8003\u4E2D...", 0); - try { - const client = new DeepSeekClient(this.settings); - const response = await client.ask(question); - const writer = new VaultWriter(this.app, this.settings); - const file = await writer.writeQANote(question, response); - notice.hide(); - new import_obsidian4.Notice(`\u2705 \u7B14\u8BB0\u5DF2\u751F\u6210\uFF1A${file.name}`); - const leaf = this.app.workspace.getLeaf(false); - await leaf.openFile(file); - if (this.settings.autoOpenGraph) { - this.app.commands.executeCommandById("graph:open"); - } - } catch (err) { - notice.hide(); - new import_obsidian4.Notice(`\u274C \u9519\u8BEF\uFF1A${err.message}`); - console.error("[DeepSeek Plugin]", err); - } - } - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - } - async saveSettings() { - await this.saveData(this.settings); - } -}; diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 87dae615ba0928371080824dffdf39141f4ba6d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHLu}T9$5S@(|JhTadXeC$V15Bay8DgnTz*>`N61e1ox!4Kkmsp66c8Wh>BlrR4 z5BvZt3kzp74(z_m&dk2Kce&kc06>qMt|~+EaBw`9jw< z6=T)~m&NNP)+IBSS=V=QR$Z`8dU>c5eM=(y*{l>zpIOIgYqukHeB_$^d48KWAl@RJ z;8Pkw1c}YOJhZ(g_xNV#Ot_7FlRZBoho5SgMdP;s{KZ+kId@M^0jI#P6rlAXz(yDf zri^;&z)l|l5Ir2$hSzkKpqxN36igY>gQhqYkyDi%F%+lccp!11V9LnppfoeiBg{%p zC`z;Ac%Z{Ug^aFu3OEI91?Ko;iT3~Puh0K>lKbQoa0<+n0?Mn^YjoI^y)|=lwATjM t_OX#sT*|0Zu#@drU(i-e|0`%iUqBa#p): this { + this.ops.push({ type: "update-frontmatter", path, fields }); + return this; + } + + build(): ExecutionPlan { + return { + id: this.generateId(), + title: this.title, + source: this.source, + operations: this.ops, + previewMarkdown: this.renderPreview(), + riskLevel: this.assessRisk(), + createdAt: new Date().toISOString(), + }; + } + + // ── Internals ────────────────────────────────────────────── + + private generateId(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 6); + return `exec-${ts}-${rand}`; + } + + private assessRisk(): RiskLevel { + const fileCount = new Set(this.ops.map((op) => this.getPath(op))).size; + const hasMoves = this.ops.some((op) => op.type === "move-file"); + const hasModifies = this.ops.some((op) => op.type === "modify-file"); + + if (fileCount >= 5 || hasMoves) return "high"; + if (fileCount >= 3 || hasModifies) return "medium"; + return "low"; + } + + private renderPreview(): string { + const lines: string[] = [`## 执行计划:${this.title}\n`]; + const grouped = this.groupByPath(); + + for (const [path, ops] of grouped) { + lines.push(`### \`${path}\``); + for (const op of ops) { + lines.push(`- ${this.describeOp(op)}`); + } + lines.push(""); + } + + lines.push(`---`); + lines.push(`影响文件:${grouped.size} 个 | 操作数:${this.ops.length} | 风险等级:${this.assessRisk()}`); + return lines.join("\n"); + } + + private groupByPath(): Map { + const map = new Map(); + for (const op of this.ops) { + const key = this.getPath(op); + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(op); + } + return map; + } + + private getPath(op: VaultOperation): string { + switch (op.type) { + case "move-file": return op.from; + default: return (op as { path: string }).path; + } + } + + private describeOp(op: VaultOperation): string { + switch (op.type) { + case "create-file": return `创建文件`; + case "modify-file": return op.description ?? `修改文件内容`; + case "append-section": return `追加到 §${op.section}`; + case "replace-selection": return `替换选中文本`; + case "move-file": return `移动到 \`${op.to}\``; + case "create-link": return `添加链接 → [[${op.target}]]`; + case "update-frontmatter": return `更新 frontmatter: ${Object.keys(op.fields).join(", ")}`; + } + } +} diff --git a/src/core/execution/PlanExecutor.ts b/src/core/execution/PlanExecutor.ts new file mode 100644 index 0000000..c4838a2 --- /dev/null +++ b/src/core/execution/PlanExecutor.ts @@ -0,0 +1,219 @@ +import { App, TFile, normalizePath, parseYaml, stringifyYaml } from "obsidian"; +import { ExecutionPlan, ExecutionRecord, VaultOperation } from "./types"; +import { SCHEMA_VERSION, todayIso } from "../schema"; + +/** + * PlanExecutor — applies an ExecutionPlan to the vault and records the result. + * + * Current scope (v1): + * - Executes operations sequentially. + * - Records the result as a markdown note in `Knowledge/_Executions/`. + * - Does NOT support rollback yet (planned for v3). + */ +export class PlanExecutor { + constructor(private app: App) {} + + /** + * Apply all operations in a plan. + * Returns an ExecutionRecord with the outcome. + */ + async execute(plan: ExecutionPlan): Promise { + const affectedPaths: string[] = []; + let error: string | undefined; + + try { + for (const op of plan.operations) { + await this.applyOp(op); + affectedPaths.push(this.getPath(op)); + } + } catch (err) { + error = (err as Error).message; + } + + const record: ExecutionRecord = { + plan, + executedAt: new Date().toISOString(), + success: !error, + affectedPaths: [...new Set(affectedPaths)], + error, + }; + + await this.persistRecord(record); + return record; + } + + // ── Apply individual operations ──────────────────────────── + + private async applyOp(op: VaultOperation): Promise { + switch (op.type) { + case "create-file": + await this.ensureParentFolder(op.path); + await this.app.vault.create(op.path, op.content); + break; + + case "modify-file": { + const file = this.getFile(op.path); + await this.app.vault.modify(file, op.content); + break; + } + + case "append-section": { + const file = this.getFile(op.path); + const content = await this.app.vault.read(file); + const updated = this.appendToSection(content, op.section, op.content); + await this.app.vault.modify(file, updated); + break; + } + + case "replace-selection": { + const file = this.getFile(op.path); + const content = await this.app.vault.read(file); + const updated = content.replace(op.oldText, op.newText); + await this.app.vault.modify(file, updated); + break; + } + + case "move-file": { + const file = this.getFile(op.from); + await this.ensureParentFolder(op.to); + await this.app.vault.rename(file, op.to); + break; + } + + case "create-link": { + const file = this.getFile(op.path); + const content = await this.app.vault.read(file); + const link = `[[${op.target}]]`; + if (content.includes(link)) break; // no duplicates + + if (op.location === "end") { + await this.app.vault.modify(file, content.trimEnd() + `\n- ${link}\n`); + } else { + // Append under section heading + const updated = this.appendToSection(content, op.location, `- ${link}`); + await this.app.vault.modify(file, updated); + } + break; + } + + case "update-frontmatter": { + const file = this.getFile(op.path); + const content = await this.app.vault.read(file); + const updated = this.patchFrontmatter(content, op.fields); + await this.app.vault.modify(file, updated); + break; + } + } + } + + // ── Helpers ──────────────────────────────────────────────── + + private getFile(path: string): TFile { + const f = this.app.vault.getAbstractFileByPath(path); + if (!f || !(f instanceof TFile)) { + throw new Error(`文件不存在:${path}`); + } + return f; + } + + private async ensureParentFolder(filePath: string): Promise { + const parts = filePath.split("/"); + parts.pop(); // remove filename + if (parts.length === 0) return; + const folder = normalizePath(parts.join("/")); + if (!this.app.vault.getAbstractFileByPath(folder)) { + await this.app.vault.createFolder(folder); + } + } + + private appendToSection(content: string, sectionName: string, text: string): string { + const regex = new RegExp(`(^##\\s+${this.escapeRegex(sectionName)}\\s*\\n)`, "m"); + const match = content.match(regex); + if (match && match.index !== undefined) { + const insertPos = match.index + match[0].length; + return content.slice(0, insertPos) + text + "\n" + content.slice(insertPos); + } + // Section not found — append at end + return content.trimEnd() + `\n\n## ${sectionName}\n${text}\n`; + } + + private patchFrontmatter(content: string, fields: Record): string { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!fmMatch) { + // No frontmatter — create one + const fm = stringifyYaml(fields); + return `---\n${fm}---\n\n${content}`; + } + try { + const existing = parseYaml(fmMatch[1]) as Record ?? {}; + const merged = { ...existing, ...fields }; + return `---\n${stringifyYaml(merged)}---\n\n${fmMatch[2]}`; + } catch { + return content; + } + } + + private escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + // ── Persist execution log ────────────────────────────────── + + private async persistRecord(record: ExecutionRecord): Promise { + const folder = normalizePath("Knowledge/_Executions"); + if (!this.app.vault.getAbstractFileByPath(folder)) { + await this.app.vault.createFolder(folder); + } + + const date = todayIso(); + const safeName = record.plan.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40); + const fileName = `${date}-${safeName}.md`; + let path = normalizePath(`${folder}/${fileName}`); + + // Conflict-safe + let suffix = 2; + while (this.app.vault.getAbstractFileByPath(path)) { + path = normalizePath(`${folder}/${date}-${safeName}-${suffix}.md`); + suffix++; + } + + const content = this.renderRecord(record); + await this.app.vault.create(path, content); + } + + private renderRecord(record: ExecutionRecord): string { + const { plan } = record; + const statusEmoji = record.success ? "✅" : "❌"; + return `--- +type: execution +schema_version: ${SCHEMA_VERSION} +status: ${record.success ? "done" : "failed"} +source: ${plan.source} +plan_id: ${plan.id} +risk_level: ${plan.riskLevel} +operations_count: ${plan.operations.length} +affected_files: ${record.affectedPaths.length} +executed_at: ${record.executedAt} +created_at: ${plan.createdAt} +--- + +# ${statusEmoji} ${plan.title} + +## 执行计划 + +${plan.previewMarkdown} + +## 影响文件 + +${record.affectedPaths.map((p) => `- [[${p}]]`).join("\n")} +${record.error ? `\n## 错误\n\n\`\`\`\n${record.error}\n\`\`\`\n` : ""} +`; + } + + private getPath(op: VaultOperation): string { + switch (op.type) { + case "move-file": return op.from; + default: return (op as { path: string }).path; + } + } +} diff --git a/src/core/execution/index.ts b/src/core/execution/index.ts new file mode 100644 index 0000000..2b770f6 --- /dev/null +++ b/src/core/execution/index.ts @@ -0,0 +1,16 @@ +export { PlanBuilder } from "./PlanBuilder"; +export { PlanExecutor } from "./PlanExecutor"; +export type { + ExecutionPlan, + ExecutionRecord, + VaultOperation, + CreateFileOp, + ModifyFileOp, + AppendSectionOp, + ReplaceSelectionOp, + MoveFileOp, + CreateLinkOp, + UpdateFrontmatterOp, + RiskLevel, + PlanSource, +} from "./types"; diff --git a/src/core/execution/types.ts b/src/core/execution/types.ts new file mode 100644 index 0000000..529c8fd --- /dev/null +++ b/src/core/execution/types.ts @@ -0,0 +1,113 @@ +/** + * ExecutionPlan — the core data structure for the "knowledge → action" pipeline. + * + * Every vault-modifying batch that the plugin performs (from AI or user request) + * should first produce an ExecutionPlan, present a preview to the user, and only + * apply changes after confirmation. + * + * Future: add rollback, dry-run, and diff rendering. + */ + +/** A single atomic vault operation. */ +export type VaultOperation = + | CreateFileOp + | ModifyFileOp + | AppendSectionOp + | ReplaceSelectionOp + | MoveFileOp + | CreateLinkOp + | UpdateFrontmatterOp; + +export interface CreateFileOp { + type: "create-file"; + path: string; + content: string; +} + +export interface ModifyFileOp { + type: "modify-file"; + path: string; + /** The full new content. */ + content: string; + /** Optional description of what changed. */ + description?: string; +} + +export interface AppendSectionOp { + type: "append-section"; + path: string; + section: string; + content: string; +} + +export interface ReplaceSelectionOp { + type: "replace-selection"; + path: string; + /** The text to be replaced (used for display; actual replacement is cursor-based). */ + oldText: string; + newText: string; +} + +export interface MoveFileOp { + type: "move-file"; + from: string; + to: string; +} + +export interface CreateLinkOp { + type: "create-link"; + /** The file where the link is added. */ + path: string; + /** The target to link to. */ + target: string; + /** Where in the file to add (section heading, or "end"). */ + location: string; +} + +export interface UpdateFrontmatterOp { + type: "update-frontmatter"; + path: string; + /** Fields to set or overwrite. */ + fields: Record; +} + +/** Risk level — determined by how many files are touched and the nature of ops. */ +export type RiskLevel = "low" | "medium" | "high"; + +/** Source feature that generated this plan. */ +export type PlanSource = + | "assistant" + | "reading" + | "question" + | "concept" + | "sync" + | "beautify" + | "manual"; + +/** + * An ExecutionPlan groups one or more VaultOperations that logically belong + * together and should be previewed/confirmed as a unit. + */ +export interface ExecutionPlan { + id: string; + title: string; + source: PlanSource; + operations: VaultOperation[]; + /** Markdown preview shown to the user before apply. */ + previewMarkdown: string; + riskLevel: RiskLevel; + createdAt: string; // ISO datetime +} + +/** + * Record of a completed execution, persisted as a note + * in `Knowledge/_Executions/`. + */ +export interface ExecutionRecord { + plan: ExecutionPlan; + executedAt: string; + success: boolean; + /** Paths that were actually modified (subset of plan if partial failure). */ + affectedPaths: string[]; + error?: string; +} diff --git a/src/core/knowledge/KnowledgeIndexService.ts b/src/core/knowledge/KnowledgeIndexService.ts new file mode 100644 index 0000000..88b46be --- /dev/null +++ b/src/core/knowledge/KnowledgeIndexService.ts @@ -0,0 +1,293 @@ +import { App, TFile, CachedMetadata, normalizePath } from "obsidian"; + +/** + * A single indexed vault entry. + * Derived from Obsidian's metadataCache + file stats — no embedding, no external DB. + */ +export interface IndexEntry { + path: string; + basename: string; + title: string; // first H1 or basename + type: string | undefined; // frontmatter.type + domain: string | undefined; // frontmatter.domain + status: string | undefined; // frontmatter.status + tags: string[]; + headings: string[]; // all ## headings + links: string[]; // outgoing [[links]] + backlinks: string[]; // populated after full scan + concepts: string[]; // frontmatter.concepts (for questions) + mtime: number; +} + +export interface SearchResult { + entry: IndexEntry; + score: number; + /** Which matching criteria contributed. */ + matchedOn: ("title" | "tag" | "heading" | "link" | "backlink" | "concept" | "domain" | "keyword")[]; +} + +export interface SearchOptions { + /** Max results to return. Default 10. */ + limit?: number; + /** Filter by frontmatter type. */ + types?: string[]; + /** Filter by domain. */ + domains?: string[]; + /** Boost entries that link to or are linked from this file path. */ + contextFile?: string; +} + +/** + * KnowledgeIndexService — lightweight in-memory index built from metadataCache. + * + * Design: + * - No embedding, no external dependency. + * - Built once on load, updated incrementally via metadataCache events. + * - Three-layer scoring: exact match → structural (links/backlinks/domain) → keyword. + * + * Usage: + * ``` + * const idx = new KnowledgeIndexService(app); + * idx.rebuild(); + * const results = idx.search("并发控制"); + * ``` + */ +export class KnowledgeIndexService { + private entries: Map = new Map(); + + constructor(private app: App) {} + + // ── Build / Rebuild ──────────────────────────────────────── + + /** Full rebuild. Call once on plugin load. */ + rebuild(): void { + this.entries.clear(); + const files = this.app.vault.getMarkdownFiles(); + for (const file of files) { + this.indexFile(file); + } + this.computeBacklinks(); + } + + /** Incremental update for a single file. */ + updateFile(file: TFile): void { + this.indexFile(file); + // Recompute backlinks (cheap for incremental — just re-scan outgoing of this file) + this.computeBacklinks(); + } + + /** Remove a file from the index. */ + removeFile(path: string): void { + this.entries.delete(path); + // Clean backlinks pointing to this file + for (const entry of this.entries.values()) { + entry.backlinks = entry.backlinks.filter((b) => b !== path); + } + } + + // ── Search ───────────────────────────────────────────────── + + /** + * Search the index for entries relevant to the given query. + * + * Scoring layers: + * 1. Exact title/basename match (score += 10) + * 2. Tag match (score += 5) + * 3. Heading match (score += 3) + * 4. Link/backlink match (score += 4) + * 5. Domain match (score += 3) + * 6. Concept list match (score += 4) + * 7. Keyword substring in title/headings (score += 2) + * + * If `contextFile` is provided, entries linked from/to it get +3 boost. + */ + search(query: string, options: SearchOptions = {}): SearchResult[] { + const { limit = 10, types, domains, contextFile } = options; + const terms = this.tokenize(query); + if (terms.length === 0) return []; + + const contextLinks = contextFile ? this.getLinksOf(contextFile) : new Set(); + + const results: SearchResult[] = []; + + for (const entry of this.entries.values()) { + // Filters + if (types && types.length > 0 && (!entry.type || !types.includes(entry.type))) continue; + if (domains && domains.length > 0 && (!entry.domain || !domains.includes(entry.domain))) continue; + + let score = 0; + const matchedOn: SearchResult["matchedOn"] = []; + + for (const term of terms) { + const lower = term.toLowerCase(); + + // 1. Title / basename + if (entry.title.toLowerCase().includes(lower) || entry.basename.toLowerCase().includes(lower)) { + score += entry.title.toLowerCase() === lower ? 10 : 5; + if (!matchedOn.includes("title")) matchedOn.push("title"); + } + + // 2. Tags + if (entry.tags.some((t) => t.toLowerCase().includes(lower))) { + score += 5; + if (!matchedOn.includes("tag")) matchedOn.push("tag"); + } + + // 3. Headings + if (entry.headings.some((h) => h.toLowerCase().includes(lower))) { + score += 3; + if (!matchedOn.includes("heading")) matchedOn.push("heading"); + } + + // 4. Outgoing links + if (entry.links.some((l) => l.toLowerCase().includes(lower))) { + score += 4; + if (!matchedOn.includes("link")) matchedOn.push("link"); + } + + // 5. Concepts + if (entry.concepts.some((c) => c.toLowerCase().includes(lower))) { + score += 4; + if (!matchedOn.includes("concept")) matchedOn.push("concept"); + } + + // 6. Domain + if (entry.domain && entry.domain.toLowerCase().includes(lower)) { + score += 3; + if (!matchedOn.includes("domain")) matchedOn.push("domain"); + } + } + + // Context boost + if (contextFile && (contextLinks.has(entry.path) || entry.links.includes(contextFile))) { + score += 3; + } + + if (score > 0) { + results.push({ entry, score, matchedOn }); + } + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, limit); + } + + /** Get all entries of a given type. */ + getByType(type: string): IndexEntry[] { + return [...this.entries.values()].filter((e) => e.type === type); + } + + /** Get all entries in a given domain. */ + getByDomain(domain: string): IndexEntry[] { + return [...this.entries.values()].filter((e) => e.domain === domain); + } + + /** Get known domains. */ + getDomains(): string[] { + const set = new Set(); + for (const e of this.entries.values()) { + if (e.domain) set.add(e.domain); + } + return [...set].sort(); + } + + /** Get all concept names (type=concept entries). */ + getConceptNames(): string[] { + return [...this.entries.values()] + .filter((e) => e.type === "concept") + .map((e) => e.basename); + } + + /** Get entry by path. */ + get(path: string): IndexEntry | undefined { + return this.entries.get(path); + } + + /** Total entries in the index. */ + get size(): number { + return this.entries.size; + } + + // ── Internals ────────────────────────────────────────────── + + private indexFile(file: TFile): void { + const meta: CachedMetadata | null = this.app.metadataCache.getFileCache(file); + const fm = meta?.frontmatter; + + const title = meta?.headings?.find((h) => h.level === 1)?.heading ?? file.basename; + const headings = (meta?.headings ?? []) + .filter((h) => h.level === 2) + .map((h) => h.heading); + const tags = [ + ...(meta?.tags ?? []).map((t) => t.tag.replace(/^#/, "")), + ...((fm?.tags as string[] | undefined) ?? []), + ]; + const links = (meta?.links ?? []).map((l) => l.link); + const concepts: string[] = Array.isArray(fm?.concepts) ? (fm!.concepts as string[]) : []; + + const entry: IndexEntry = { + path: file.path, + basename: file.basename, + title, + type: fm?.type as string | undefined, + domain: fm?.domain as string | undefined, + status: fm?.status as string | undefined, + tags, + headings, + links, + backlinks: this.entries.get(file.path)?.backlinks ?? [], + concepts, + mtime: file.stat.mtime, + }; + + this.entries.set(file.path, entry); + } + + private computeBacklinks(): void { + // Reset all backlinks + for (const entry of this.entries.values()) { + entry.backlinks = []; + } + // Build + for (const entry of this.entries.values()) { + for (const link of entry.links) { + // Resolve link to a path + const resolved = this.resolveLink(link, entry.path); + if (resolved) { + const target = this.entries.get(resolved); + if (target && !target.backlinks.includes(entry.path)) { + target.backlinks.push(entry.path); + } + } + } + } + } + + private resolveLink(link: string, fromPath: string): string | null { + // Use Obsidian's resolve to handle relative paths, aliases, etc. + const file = this.app.metadataCache.getFirstLinkpathDest(link, fromPath); + return file?.path ?? null; + } + + private getLinksOf(path: string): Set { + const entry = this.entries.get(path); + if (!entry) return new Set(); + const set = new Set(); + for (const link of entry.links) { + const resolved = this.resolveLink(link, path); + if (resolved) set.add(resolved); + } + for (const bl of entry.backlinks) { + set.add(bl); + } + return set; + } + + private tokenize(query: string): string[] { + // Split on whitespace, punctuation, and common separators + return query + .split(/[\s,;、,;]+/) + .map((t) => t.trim()) + .filter((t) => t.length >= 1); + } +} diff --git a/src/core/knowledge/index.ts b/src/core/knowledge/index.ts new file mode 100644 index 0000000..ea3411c --- /dev/null +++ b/src/core/knowledge/index.ts @@ -0,0 +1,2 @@ +export { KnowledgeIndexService } from "./KnowledgeIndexService"; +export type { IndexEntry, SearchResult, SearchOptions } from "./KnowledgeIndexService"; diff --git a/src/core/llm/JsonExtractor.ts b/src/core/llm/JsonExtractor.ts new file mode 100644 index 0000000..b39986c --- /dev/null +++ b/src/core/llm/JsonExtractor.ts @@ -0,0 +1,37 @@ +/** + * Tolerant JSON extractor for LLM outputs. + * + * LLMs frequently wrap JSON in ```json ... ``` fences or surround it with + * explanatory prose. These helpers locate and parse the JSON payload safely. + */ + +/** Locate the JSON-looking substring inside an LLM response. */ +export function extractJson(raw: string): string { + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) return fenced[1]; + + const braced = raw.match(/(\{[\s\S]*\})/); + if (braced) return braced[1]; + + return raw; +} + +/** + * Parse JSON from an LLM response, returning `fallback` on any failure. + * Never throws. + */ +export function parseJsonSafe(raw: string, fallback: T): T { + try { + return JSON.parse(extractJson(raw).trim()) as T; + } catch { + return fallback; + } +} + +/** + * Parse JSON from an LLM response. Throws if the payload is not valid JSON. + * Use when an empty/default value is not acceptable upstream. + */ +export function parseJsonStrict(raw: string): T { + return JSON.parse(extractJson(raw).trim()) as T; +} diff --git a/src/core/llm/LLMClient.ts b/src/core/llm/LLMClient.ts new file mode 100644 index 0000000..2a56b6b --- /dev/null +++ b/src/core/llm/LLMClient.ts @@ -0,0 +1,87 @@ +import { requestUrl } from "obsidian"; +import { DeepSeekSettings } from "../../types"; + +/** + * Unified LLM chat-completion client. + * + * Centralizes: + * - OpenAI-compatible chat-completions request shape + * - API-key validation + * - Error normalization + * - Default headers / timeouts + * + * All AI feature modules (assistant, classifier, completer, planner, ...) + * should use this client instead of calling `requestUrl` directly. + * + * Provider-agnostic by design: the request payload conforms to the + * `chat/completions` schema used by DeepSeek, OpenAI, and most compatible + * providers. Adding a new provider should only require subclassing or + * swapping out this module. + */ + +export interface LLMChatOptions { + /** Optional system prompt. Sent as the first message when provided. */ + systemPrompt?: string; + /** Required user prompt. */ + userPrompt: string; + /** Sampling temperature. Defaults to 0.5. */ + temperature?: number; + /** + * Override the model name configured in settings. + * Useful for tasks that always need a specific model. + */ + model?: string; +} + +export class LLMError extends Error { + constructor(public status: number, message: string, public body?: string) { + super(message); + this.name = "LLMError"; + } +} + +export class LLMClient { + constructor(private settings: DeepSeekSettings) {} + + /** Throw a friendly error if the API key is not configured. */ + ensureApiKey(): void { + if (!this.settings.apiKey) { + throw new Error("请先在插件设置中配置 API Key"); + } + } + + /** + * Run a single chat-completion request. + * @returns The raw assistant message content (string, possibly empty). + */ + async chat(options: LLMChatOptions): Promise { + this.ensureApiKey(); + + const messages: { role: "system" | "user"; content: string }[] = []; + if (options.systemPrompt) { + messages.push({ role: "system", content: options.systemPrompt }); + } + messages.push({ role: "user", content: options.userPrompt }); + + const res = await requestUrl({ + url: `${this.settings.baseUrl}/v1/chat/completions`, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.settings.apiKey}`, + }, + body: JSON.stringify({ + model: options.model ?? this.settings.model, + messages, + temperature: options.temperature ?? 0.5, + }), + throw: false, + }); + + if (res.status !== 200) { + throw new LLMError(res.status, `LLM API 错误: ${res.status}`, res.text); + } + + return res.json?.choices?.[0]?.message?.content ?? ""; + } +} diff --git a/src/core/llm/index.ts b/src/core/llm/index.ts new file mode 100644 index 0000000..00e4c87 --- /dev/null +++ b/src/core/llm/index.ts @@ -0,0 +1,3 @@ +export { LLMClient, LLMError } from "./LLMClient"; +export type { LLMChatOptions } from "./LLMClient"; +export { extractJson, parseJsonSafe, parseJsonStrict } from "./JsonExtractor"; diff --git a/src/core/schema.ts b/src/core/schema.ts new file mode 100644 index 0000000..8803e0b --- /dev/null +++ b/src/core/schema.ts @@ -0,0 +1,20 @@ +/** + * Frontmatter schema version for plugin-managed notes. + * + * Bump when: + * - The shape of frontmatter fields changes (rename/remove/retype). + * - The semantics of an existing field change. + * + * Migration code lives in `src/core/schema/migrations/` (added on first bump). + * + * Currently affects: + * - Concept pages (`type: concept`) + * - Question Q&A notes (`type: question`) + * - Domain index pages (`type: domain-index`) + */ +export const SCHEMA_VERSION = 1; + +/** Today's date in ISO `YYYY-MM-DD` form, in the local timezone. */ +export function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/src/features/.DS_Store b/src/features/.DS_Store deleted file mode 100644 index a1388ff6d4cf5644741c4d36ae62f49728c5b5db..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMzl#(x6n^m*c0F;GgM({?DQynCR!hc(AM5@A?FO#6#zF+q z(nhgSI~%dKw-EmZxza+=!tW(O z&!0h2lenI9C7sf5F2V}<6OCy?T}o*Z_bX`s!Ykkv@CtYZyaHZ<|3Lwq*_>LAb6?f^ z(ktK_458dn zA21v?)HYXExsy=tBy`V0M<_z}4!=OcNrcM!(ktKqd4xH z=+&3#eGb~>yR!7+{Z++xu1CpxF%O%607BH1{8N;BV2-;Hlk=_XRfNg&-p%R3isCub zPLh?}+$fvR4)a;od?w)Y5H~jK^nkiHtL9r5&g3?Y3U0M+nhcM^+C1gFH!;@(%yged zC=;7u`xh9_AWUx0A3u3o=w`KJvT~zp__UzY6q@lZ*8nRrwHaOFqo{&0IlWpf^s2#? zvpElE^9s==R2YF(SE`j#-)6PhtRhTK`xox)Z~k3hRy(FnZd5IwG5AFFDlOlTd@O{? z=iQr0x!z|`r9y6$%}1`IHfD_Xj*(pJYA+d54378$Y_-}c*v-Y8TtEK&^8K>X?g*cf z`Jz5G<7i+UkFWzqHs>E7z`3Y1`F%Qk_F8`fMt#YpuHNcb1Zdi)W2(TC8rK@f|2TzQ z_y5Oq^nO@g0j~fnAR3+R&K5s7^x#-KK;A*-#JF5p>4HLruKfeg@=YJ82`SV{c!4p~h0eX+V