mirror of
https://github.com/hendrikhuwy-lgtm/codex-vault-agent.git
synced 2026-07-22 06:51:20 +00:00
2291 lines
86 KiB
JavaScript
2291 lines
86 KiB
JavaScript
const { spawn } = require("child_process");
|
||
const { ItemView, Modal, Notice, Plugin, PluginSettingTab, Setting, setIcon } = require("obsidian");
|
||
|
||
const VIEW_TYPE = "codex-vault-agent";
|
||
const DEFAULT_CODEX_BIN = "codex";
|
||
const DEFAULT_FOLDER = "";
|
||
const DEFAULT_OUTPUT_FOLDER = "Vault Agent Reports";
|
||
const DEFAULT_OUTPUT_PATH = `${DEFAULT_OUTPUT_FOLDER}/Query Report.md`;
|
||
const DEFAULT_SETTINGS = {
|
||
defaultFolder: DEFAULT_FOLDER,
|
||
outputFolder: DEFAULT_OUTPUT_FOLDER,
|
||
includeSubfolders: true,
|
||
maxEvidenceRows: 60,
|
||
metadataFields: "type, status, topic",
|
||
codexBin: DEFAULT_CODEX_BIN,
|
||
codexEnabled: true,
|
||
};
|
||
const REPORT_TYPES = {
|
||
"query-report": {
|
||
label: "Query report",
|
||
purpose: "Evidence packet for answering the Ask question.",
|
||
},
|
||
dashboard: {
|
||
label: "Dashboard",
|
||
purpose: "Reusable Dataview dashboard and review queues.",
|
||
},
|
||
"link-audit": {
|
||
label: "Link audit",
|
||
purpose: "Backlink, outlink, and hub-note cleanup plan.",
|
||
},
|
||
"metadata-audit": {
|
||
label: "Metadata audit",
|
||
purpose: "Frontmatter completeness and normalization tasks.",
|
||
},
|
||
};
|
||
const CODEX_PATH = [
|
||
`${process.env.HOME || ""}/.local/bin`,
|
||
`${process.env.HOME || ""}/bin`,
|
||
"/opt/homebrew/bin",
|
||
"/usr/local/bin",
|
||
"/usr/bin",
|
||
"/bin",
|
||
"/usr/sbin",
|
||
"/sbin",
|
||
].filter(Boolean).join(":");
|
||
const STOPWORDS = new Set([
|
||
"about",
|
||
"after",
|
||
"also",
|
||
"and",
|
||
"are",
|
||
"before",
|
||
"connect",
|
||
"collect",
|
||
"dataview",
|
||
"find",
|
||
"field",
|
||
"fields",
|
||
"for",
|
||
"from",
|
||
"how",
|
||
"include",
|
||
"into",
|
||
"let",
|
||
"notes",
|
||
"only",
|
||
"review",
|
||
"search",
|
||
"semantic",
|
||
"show",
|
||
"that",
|
||
"the",
|
||
"then",
|
||
"this",
|
||
"use",
|
||
"using",
|
||
"with",
|
||
]);
|
||
|
||
function buildCodexEnv() {
|
||
return Object.assign({}, process.env, {
|
||
HOME: process.env.HOME || "",
|
||
PATH: `${CODEX_PATH}${process.env.PATH ? `:${process.env.PATH}` : ""}`,
|
||
});
|
||
}
|
||
|
||
function createInitialAgentState(settings = DEFAULT_SETTINGS) {
|
||
const folder = settings.defaultFolder || DEFAULT_FOLDER;
|
||
const outputFolder = settings.outputFolder || DEFAULT_OUTPUT_FOLDER;
|
||
return {
|
||
query:
|
||
"Find notes that connect a topic, related ideas, and useful links. Use Dataview for structured fields and include semantic matches only after review.",
|
||
mode: "hybrid",
|
||
dataviewQuery: `TABLE type, status, topic, file.mtime
|
||
${folder ? `FROM "${folder}"` : ""}
|
||
WHERE true
|
||
SORT file.mtime DESC`,
|
||
scope: {
|
||
currentNote: true,
|
||
backlinks: true,
|
||
outlinks: true,
|
||
includeSubfolders: Boolean(settings.includeSubfolders ?? DEFAULT_SETTINGS.includeSubfolders),
|
||
includeEveryday: false,
|
||
includeArchive: false,
|
||
},
|
||
collection: {
|
||
maxEvidenceRows: settings.maxEvidenceRows || DEFAULT_SETTINGS.maxEvidenceRows,
|
||
metadataFields: settings.metadataFields || DEFAULT_SETTINGS.metadataFields,
|
||
},
|
||
folder,
|
||
reportType: "query-report",
|
||
outputPath: `${outputFolder}/Query Report.md`,
|
||
sections: {
|
||
frontmatter: true,
|
||
dataview: true,
|
||
files: true,
|
||
evidence: true,
|
||
synthesis: true,
|
||
links: true,
|
||
metadata: true,
|
||
},
|
||
evidenceRows: [],
|
||
collapsedEvidenceGroups: {},
|
||
lastRun: null,
|
||
isCollecting: false,
|
||
collectionMessage: "Run collection to build an evidence set from the selected scope.",
|
||
codexSynthesis: createInitialSynthesisState(),
|
||
};
|
||
}
|
||
|
||
function createInitialSynthesisState() {
|
||
return {
|
||
status: "idle",
|
||
markdown: "",
|
||
error: "",
|
||
threadId: null,
|
||
turnId: null,
|
||
generatedAt: null,
|
||
selectedCount: 0,
|
||
reportType: null,
|
||
detail: "Run after reviewing evidence. Codex receives selected rows only.",
|
||
};
|
||
}
|
||
|
||
class CodexAppServerClient {
|
||
constructor(options) {
|
||
this.bin = options.bin;
|
||
this.cwd = options.cwd;
|
||
this.nextId = 1;
|
||
this.pending = new Map();
|
||
this.stdoutBuffer = "";
|
||
this.stderrBuffer = "";
|
||
this.proc = null;
|
||
this.notificationHandlers = new Set();
|
||
}
|
||
|
||
async connect() {
|
||
if (this.proc) return;
|
||
|
||
this.proc = spawn(this.bin, ["app-server"], {
|
||
cwd: this.cwd,
|
||
env: buildCodexEnv(),
|
||
stdio: ["pipe", "pipe", "pipe"],
|
||
});
|
||
|
||
this.proc.stdout.on("data", (chunk) => this.handleStdout(chunk));
|
||
this.proc.stderr.on("data", (chunk) => {
|
||
this.stderrBuffer = `${this.stderrBuffer}${chunk.toString()}`.slice(-4000);
|
||
});
|
||
this.proc.on("error", (error) => this.rejectAll(error));
|
||
this.proc.on("exit", (code, signal) => {
|
||
const suffix = signal ? `signal ${signal}` : `code ${code}`;
|
||
const detail = this.stderrBuffer.trim();
|
||
this.rejectAll(new Error(detail ? `Codex app-server exited with ${suffix}. ${detail}` : `Codex app-server exited with ${suffix}.`));
|
||
this.proc = null;
|
||
});
|
||
|
||
await this.request("initialize", {
|
||
clientInfo: {
|
||
name: "obsidian-vault-agent",
|
||
title: "Obsidian Vault Agent",
|
||
version: "0.1.0",
|
||
},
|
||
capabilities: null,
|
||
});
|
||
this.notify("initialized", {});
|
||
}
|
||
|
||
request(method, params = {}, timeoutMs = 15000) {
|
||
if (!this.proc || !this.proc.stdin.writable) {
|
||
return Promise.reject(new Error("Codex app-server is not running."));
|
||
}
|
||
|
||
const id = this.nextId++;
|
||
return new Promise((resolve, reject) => {
|
||
const timeout = window.setTimeout(() => {
|
||
this.pending.delete(id);
|
||
const detail = this.stderrBuffer.trim();
|
||
reject(new Error(detail ? `${method} timed out. ${detail}` : `${method} timed out.`));
|
||
}, timeoutMs);
|
||
|
||
this.pending.set(id, { resolve, reject, timeout });
|
||
this.proc.stdin.write(`${JSON.stringify({ method, id, params })}\n`);
|
||
});
|
||
}
|
||
|
||
notify(method, params = {}) {
|
||
if (!this.proc || !this.proc.stdin.writable) return;
|
||
this.proc.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
||
}
|
||
|
||
respond(id, result) {
|
||
if (!this.proc || !this.proc.stdin.writable) return;
|
||
this.proc.stdin.write(`${JSON.stringify({ id, result })}\n`);
|
||
}
|
||
|
||
respondError(id, message) {
|
||
if (!this.proc || !this.proc.stdin.writable) return;
|
||
this.proc.stdin.write(`${JSON.stringify({ id, error: { code: -32603, message } })}\n`);
|
||
}
|
||
|
||
onNotification(handler) {
|
||
this.notificationHandlers.add(handler);
|
||
return () => this.notificationHandlers.delete(handler);
|
||
}
|
||
|
||
handleStdout(chunk) {
|
||
this.stdoutBuffer += chunk.toString();
|
||
|
||
let newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||
while (newlineIndex !== -1) {
|
||
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||
if (line) this.handleMessage(line);
|
||
newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||
}
|
||
}
|
||
|
||
handleMessage(line) {
|
||
let message;
|
||
try {
|
||
message = JSON.parse(line);
|
||
} catch (_error) {
|
||
this.stderrBuffer = `${this.stderrBuffer}\nUnparseable stdout: ${line}`.slice(-4000);
|
||
return;
|
||
}
|
||
|
||
if (!message) return;
|
||
|
||
if (typeof message.id !== "undefined" && message.method) {
|
||
this.handleServerRequest(message);
|
||
return;
|
||
}
|
||
|
||
if (typeof message.id === "undefined") {
|
||
this.emitNotification(message);
|
||
return;
|
||
}
|
||
|
||
const pending = this.pending.get(message.id);
|
||
if (!pending) return;
|
||
|
||
window.clearTimeout(pending.timeout);
|
||
this.pending.delete(message.id);
|
||
|
||
if (message.error) {
|
||
pending.reject(new Error(message.error.message || "Codex app-server request failed."));
|
||
return;
|
||
}
|
||
|
||
pending.resolve(message.result);
|
||
}
|
||
|
||
handleServerRequest(message) {
|
||
const method = message.method || "";
|
||
if (method === "item/commandExecution/requestApproval") {
|
||
this.respond(message.id, { decision: "decline" });
|
||
} else if (method === "item/fileChange/requestApproval") {
|
||
this.respond(message.id, { decision: "decline" });
|
||
} else if (method === "applyPatchApproval" || method === "execCommandApproval") {
|
||
this.respond(message.id, { decision: "denied" });
|
||
} else if (method === "item/permissions/requestApproval") {
|
||
this.respond(message.id, { permissions: {}, scope: "turn", strictAutoReview: true });
|
||
} else if (method === "item/tool/requestUserInput") {
|
||
this.respond(message.id, { answers: {} });
|
||
} else if (method === "item/tool/call") {
|
||
this.respond(message.id, {
|
||
success: false,
|
||
contentItems: [{ type: "input_text", text: "Vault Agent synthesis does not expose dynamic tools." }],
|
||
});
|
||
} else {
|
||
this.respondError(message.id, `${method || "Server request"} is not supported by Vault Agent.`);
|
||
}
|
||
}
|
||
|
||
emitNotification(message) {
|
||
for (const handler of this.notificationHandlers) {
|
||
try {
|
||
handler(message);
|
||
} catch (error) {
|
||
this.stderrBuffer = `${this.stderrBuffer}\nNotification handler failed: ${error.message || error}`.slice(-4000);
|
||
}
|
||
}
|
||
}
|
||
|
||
rejectAll(error) {
|
||
for (const [id, pending] of this.pending.entries()) {
|
||
window.clearTimeout(pending.timeout);
|
||
pending.reject(error);
|
||
this.pending.delete(id);
|
||
}
|
||
}
|
||
|
||
disconnect() {
|
||
if (!this.proc) return;
|
||
this.rejectAll(new Error("Codex app-server disconnected."));
|
||
this.proc.kill();
|
||
this.proc = null;
|
||
}
|
||
}
|
||
|
||
function el(tag, className = "", text = "") {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function div(className = "", text = "") {
|
||
return el("div", className, text);
|
||
}
|
||
|
||
function makeButton(label, className = "") {
|
||
const node = el("button", className, label);
|
||
node.type = "button";
|
||
return node;
|
||
}
|
||
|
||
function icon(name, className = "") {
|
||
const node = div(`cva-icon ${className}`.trim());
|
||
setIcon(node, name);
|
||
return node;
|
||
}
|
||
|
||
function sectionHead(iconName, title, meta = "") {
|
||
const head = div("cva-panel-head");
|
||
const left = div("cva-panel-title-wrap");
|
||
left.append(icon(iconName), div("cva-panel-title", title));
|
||
head.append(left);
|
||
if (meta) head.append(div("cva-panel-meta", meta));
|
||
return head;
|
||
}
|
||
|
||
function statusPill(label, value, tone = "") {
|
||
const pill = div(`cva-status-pill ${tone}`.trim());
|
||
pill.append(div("cva-status-label", label), div("cva-status-value", value));
|
||
return pill;
|
||
}
|
||
|
||
function tokenize(value) {
|
||
return Array.from(
|
||
new Set(
|
||
String(value)
|
||
.toLowerCase()
|
||
.split(/[^a-z0-9\u4e00-\u9fff]+/i)
|
||
.map((term) => term.trim())
|
||
.filter((term) => term.length >= 3 && !STOPWORDS.has(term))
|
||
)
|
||
).slice(0, 18);
|
||
}
|
||
|
||
function titleFromPath(path) {
|
||
return path.split("/").pop().replace(/\.md$/i, "");
|
||
}
|
||
|
||
function dirname(path) {
|
||
const index = path.lastIndexOf("/");
|
||
return index === -1 ? "" : path.slice(0, index);
|
||
}
|
||
|
||
function normalizeOutputPath(path) {
|
||
const trimmed = String(path || "").trim();
|
||
if (!trimmed) return DEFAULT_OUTPUT_PATH;
|
||
return trimmed.endsWith(".md") ? trimmed : `${trimmed}.md`;
|
||
}
|
||
|
||
function parseCsv(value, fallback = []) {
|
||
const parsed = String(value || "")
|
||
.split(",")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
return parsed.length ? parsed : fallback;
|
||
}
|
||
|
||
function folderContainsPath(folder, path, includeSubfolders = true) {
|
||
if (!folder) return true;
|
||
if (includeSubfolders) return path.startsWith(`${folder}/`);
|
||
return dirname(path) === folder;
|
||
}
|
||
|
||
function displayFolder(folder) {
|
||
return folder ? folder : "Entire vault";
|
||
}
|
||
|
||
function rewriteDataviewFolder(query, folder) {
|
||
if (!folder) return String(query || "").replace(/^\s*FROM\s+"[^"]*"\s*\n?/im, "");
|
||
const nextFrom = `FROM "${folder}"`;
|
||
if (/FROM\s+"[^"]*"/i.test(query)) return query.replace(/FROM\s+"[^"]*"/i, nextFrom);
|
||
return `${nextFrom}\n${query}`;
|
||
}
|
||
|
||
function parseDataviewPlan(query, fallbackFolder) {
|
||
const text = String(query || "");
|
||
const columnsMatch = text.match(/^\s*TABLE\s+(.+)$/im);
|
||
const fromMatch = text.match(/^\s*FROM\s+"([^"]+)"/im);
|
||
const sortMatch = text.match(/^\s*SORT\s+(.+)$/im);
|
||
const whereLines = text
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.filter((line) => /^(WHERE|OR|AND)\b/i.test(line))
|
||
.join(" ");
|
||
const termMatches = Array.from(whereLines.matchAll(/"([^"]+)"/g)).map((match) => match[1]);
|
||
|
||
return {
|
||
columns: columnsMatch ? columnsMatch[1].trim() : "not specified",
|
||
folder: fromMatch ? fromMatch[1].trim() : fallbackFolder,
|
||
terms: Array.from(new Set(termMatches)).slice(0, 8),
|
||
sort: sortMatch ? sortMatch[1].trim() : "not specified",
|
||
};
|
||
}
|
||
|
||
function uniqueTerms(values, limit = 18) {
|
||
return Array.from(
|
||
new Set(
|
||
values
|
||
.map((term) => String(term || "").trim().toLowerCase())
|
||
.filter(Boolean)
|
||
)
|
||
).slice(0, limit);
|
||
}
|
||
|
||
function escapeDataviewString(value) {
|
||
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||
}
|
||
|
||
function extractAskPhrases(value) {
|
||
const text = String(value || "").trim();
|
||
const quoted = Array.from(text.matchAll(/"([^"]+)"/g)).map((match) => match[1]);
|
||
if (quoted.length) return uniqueTerms(quoted, 10);
|
||
|
||
const firstSentence = (text.split(/[.!?\n]/).find((part) => part.trim()) || text)
|
||
.replace(
|
||
/^\s*(find|show|collect|search|list|locate|gather)\s+(notes?|files?|ideas?|links?|metadata|items?)?\s*(that\s+)?(connect|about|related\s+to|mention|with|for|on)?\s*/i,
|
||
""
|
||
)
|
||
.trim();
|
||
const chunks = firstSentence
|
||
.split(/\s*(?:,|;|\+|\band\b|\bor\b)\s*/i)
|
||
.map((chunk) => chunk.replace(/^(notes?|ideas?|links?|metadata)\s+(about|on|for|with)\s+/i, "").trim())
|
||
.filter((chunk) => chunk && !STOPWORDS.has(chunk.toLowerCase()));
|
||
return uniqueTerms(chunks.length ? chunks : tokenize(text), 10);
|
||
}
|
||
|
||
function termsOverlap(left, right) {
|
||
return left.some((first) => right.some((second) => first === second || first.includes(second) || second.includes(first)));
|
||
}
|
||
|
||
function buildDataviewQueryFromTerms(currentQuery, folder, terms) {
|
||
const parsed = parseDataviewPlan(currentQuery, folder);
|
||
const columns = parsed.columns && parsed.columns !== "not specified" ? parsed.columns : "type, status, topic, file.mtime";
|
||
const sort = parsed.sort && parsed.sort !== "not specified" ? parsed.sort : "file.mtime DESC";
|
||
const activeTerms = uniqueTerms(terms, 12);
|
||
const where = activeTerms.length
|
||
? activeTerms
|
||
.map((term, index) => {
|
||
const prefix = index === 0 ? "WHERE" : " OR";
|
||
const escaped = escapeDataviewString(term);
|
||
return `${prefix} contains(lower(string(topic)), "${escaped}")\n OR contains(lower(file.name), "${escaped}")\n OR contains(lower(file.path), "${escaped}")`;
|
||
})
|
||
.join("\n")
|
||
: "WHERE true";
|
||
|
||
return [`TABLE ${columns}`, folder ? `FROM "${folder}"` : "", where, `SORT ${sort}`].filter(Boolean).join("\n");
|
||
}
|
||
|
||
function termMatchesText(haystack, term) {
|
||
const normalized = String(term || "").trim().toLowerCase();
|
||
if (!normalized) return false;
|
||
if (normalized.length < 3 && /^[a-z0-9]+$/i.test(normalized)) {
|
||
return new RegExp(`(^|[^a-z0-9])${normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9]|$)`, "i").test(haystack);
|
||
}
|
||
return haystack.includes(normalized);
|
||
}
|
||
|
||
function yamlScalar(value) {
|
||
return JSON.stringify(String(value).replace(/\r?\n/g, " "));
|
||
}
|
||
|
||
function safeStringify(value) {
|
||
try {
|
||
return JSON.stringify(value || {});
|
||
} catch (_error) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function truncateText(value, maxLength = 600) {
|
||
const text = String(value || "").replace(/\s+/g, " ").trim();
|
||
if (text.length <= maxLength) return text;
|
||
return `${text.slice(0, maxLength - 3)}...`;
|
||
}
|
||
|
||
function wikilink(path) {
|
||
return `[[${path.replace(/\.md$/i, "")}|${titleFromPath(path)}]]`;
|
||
}
|
||
|
||
class NotePreviewModal extends Modal {
|
||
constructor(app, plugin) {
|
||
super(app);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
onOpen() {
|
||
const markdown = this.plugin.compileMarkdown();
|
||
this.contentEl.empty();
|
||
this.contentEl.addClass("cva-modal");
|
||
|
||
this.contentEl.append(div("cva-modal-title", "Query report preview"));
|
||
const path = div("cva-modal-path", normalizeOutputPath(this.plugin.agentState.outputPath));
|
||
this.contentEl.append(path);
|
||
|
||
const textarea = el("textarea", "cva-modal-text");
|
||
textarea.value = markdown;
|
||
this.contentEl.append(textarea);
|
||
|
||
const actions = div("cva-modal-actions");
|
||
const create = makeButton("Create note", "mod-cta");
|
||
const close = makeButton("Close");
|
||
create.addEventListener("click", async () => {
|
||
await this.plugin.createCompiledNote(textarea.value);
|
||
this.close();
|
||
});
|
||
close.addEventListener("click", () => this.close());
|
||
actions.append(create, close);
|
||
this.contentEl.append(actions);
|
||
}
|
||
}
|
||
|
||
class FolderPickerModal extends Modal {
|
||
constructor(app, plugin, options = {}) {
|
||
super(app);
|
||
this.plugin = plugin;
|
||
this.title = options.title || "Choose folder";
|
||
this.onChoose = options.onChoose || ((path) => this.plugin.setFolderScope(path));
|
||
this.query = "";
|
||
this.listEl = null;
|
||
}
|
||
|
||
onOpen() {
|
||
this.contentEl.empty();
|
||
this.contentEl.addClass("cva-modal");
|
||
this.contentEl.addClass("cva-folder-modal");
|
||
this.contentEl.append(div("cva-modal-title", this.title));
|
||
|
||
const search = el("input", "cva-folder-search");
|
||
search.type = "text";
|
||
search.placeholder = "Search folders...";
|
||
search.value = this.query;
|
||
search.addEventListener("input", () => {
|
||
this.query = search.value;
|
||
this.renderFolderList();
|
||
});
|
||
this.contentEl.append(search);
|
||
|
||
this.listEl = div("cva-folder-list");
|
||
this.contentEl.append(this.listEl);
|
||
this.renderFolderList();
|
||
window.setTimeout(() => search.focus(), 30);
|
||
}
|
||
|
||
renderFolderList() {
|
||
if (!this.listEl) return;
|
||
this.listEl.empty();
|
||
|
||
const needle = this.query.trim().toLowerCase();
|
||
const folders = this.plugin
|
||
.getVaultFolders()
|
||
.filter((path) => !needle || path.toLowerCase().includes(needle))
|
||
.slice(0, 80);
|
||
|
||
if (!folders.length) {
|
||
this.listEl.append(div("cva-empty", "No matching folders."));
|
||
return;
|
||
}
|
||
|
||
if (!needle) {
|
||
const allVault = makeButton("", "cva-folder-option");
|
||
const current = !this.plugin.agentState.folder;
|
||
allVault.append(icon(current ? "check" : "folder-open"), div("cva-folder-option-copy", "Entire vault"));
|
||
allVault.addEventListener("click", () => {
|
||
this.onChoose("");
|
||
this.close();
|
||
});
|
||
this.listEl.append(allVault);
|
||
}
|
||
|
||
folders.forEach((path) => {
|
||
const row = makeButton("", "cva-folder-option");
|
||
const current = path === this.plugin.agentState.folder;
|
||
row.append(icon(current ? "check" : "folder"), div("cva-folder-option-copy", path));
|
||
row.addEventListener("click", () => {
|
||
this.onChoose(path);
|
||
this.close();
|
||
});
|
||
this.listEl.append(row);
|
||
});
|
||
}
|
||
}
|
||
|
||
class CodexVaultAgentPreviewView extends ItemView {
|
||
constructor(leaf, plugin) {
|
||
super(leaf);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
getViewType() {
|
||
return VIEW_TYPE;
|
||
}
|
||
|
||
getDisplayText() {
|
||
return "Vault Agent";
|
||
}
|
||
|
||
getIcon() {
|
||
return "database-zap";
|
||
}
|
||
|
||
async onOpen() {
|
||
this.plugin.views.add(this);
|
||
this.render();
|
||
}
|
||
|
||
async onClose() {
|
||
this.plugin.views.delete(this);
|
||
}
|
||
|
||
render() {
|
||
const root = this.containerEl.children[1];
|
||
root.empty();
|
||
root.addClass("cva-root");
|
||
|
||
const shell = div("cva-shell");
|
||
root.append(shell);
|
||
shell.append(this.renderHeader(), this.renderFlowBar(), this.renderQueryStage(), this.renderWorkbench());
|
||
if (this.plugin.agentState.evidenceRows.length) shell.append(this.renderCompilePanel());
|
||
shell.append(this.renderCompileStrip());
|
||
}
|
||
|
||
renderFlowBar() {
|
||
const state = this.plugin.agentState;
|
||
const hasEvidence = state.evidenceRows.length > 0;
|
||
const hasSelected = this.plugin.includedRows().length > 0;
|
||
const flow = div("cva-flow");
|
||
[
|
||
["1", "Query", true, true],
|
||
["2", "Scope", true, true],
|
||
["3", "Collect", hasEvidence, state.isCollecting],
|
||
["4", "Review", hasSelected, hasEvidence],
|
||
["5", "Compile", false, hasSelected],
|
||
].forEach(([index, label, done, active]) => {
|
||
const step = div(`cva-flow-step ${done ? "done" : ""} ${active ? "active" : ""}`.trim());
|
||
step.append(div("cva-step-index", index), div("cva-step-label", label));
|
||
flow.append(step);
|
||
});
|
||
return flow;
|
||
}
|
||
|
||
renderHeader() {
|
||
const dataviewDetected = Boolean(this.app.plugins.plugins.dataview);
|
||
const codexState = this.plugin.codexState;
|
||
const header = div("cva-header");
|
||
|
||
const titleBlock = div("cva-title-block");
|
||
titleBlock.append(div("cva-eyebrow", "Data collector + Dataview query agent"));
|
||
titleBlock.append(
|
||
el("h1", "", "Vault Agent"),
|
||
div(
|
||
"cva-subtitle",
|
||
"Choose the evidence set, let Dataview collect the deterministic layer, then compile the reviewed result into a query report note."
|
||
)
|
||
);
|
||
|
||
const status = div("cva-status-panel");
|
||
status.append(
|
||
statusPill("Dataview", dataviewDetected ? "Detected" : "Missing", dataviewDetected ? "ok" : "warn"),
|
||
statusPill("Codex", codexState.label, codexState.tone),
|
||
statusPill("Mode", codexState.mode, codexState.modeTone)
|
||
);
|
||
const actions = div("cva-header-actions");
|
||
const connectLabel = codexState.state === "connected" ? "Disconnect Codex" : codexState.state === "connecting" ? "Connecting..." : "Connect Codex";
|
||
const connect = makeButton(connectLabel, `cva-button ${codexState.state === "connected" ? "cva-ghost" : "mod-cta"}`);
|
||
connect.disabled = codexState.state === "connecting" || !this.plugin.settings.codexEnabled;
|
||
connect.addEventListener("click", () => {
|
||
if (codexState.state === "connected") this.plugin.disconnectCodex();
|
||
else this.plugin.connectCodex();
|
||
});
|
||
actions.append(connect);
|
||
status.append(actions, div("cva-connection-note", codexState.detail));
|
||
|
||
header.append(titleBlock, status);
|
||
return header;
|
||
}
|
||
|
||
renderQueryStage() {
|
||
const state = this.plugin.agentState;
|
||
const stage = div("cva-query-stage");
|
||
|
||
const query = div("cva-query-card");
|
||
query.append(sectionHead("search", "1. Ask", "plain language request"));
|
||
query.append(div("cva-helper-text", "Describe the notes, ideas, links, or metadata you want the agent to collect."));
|
||
const textarea = el("textarea", "cva-query-input");
|
||
textarea.value = state.query;
|
||
textarea.addEventListener("input", () => {
|
||
this.plugin.updateQueryPlan({ query: textarea.value });
|
||
renderPlan(code?.value || state.dataviewQuery, textarea.value);
|
||
});
|
||
textarea.addEventListener("change", () => this.plugin.updateQueryPlan({ query: textarea.value }, true));
|
||
query.append(textarea);
|
||
|
||
const modes = div("cva-mode-row");
|
||
[
|
||
["Hybrid", "hybrid"],
|
||
["Dataview", "dataview"],
|
||
["Semantic", "semantic"],
|
||
["Exact text", "exact"],
|
||
].forEach(([label, value]) => {
|
||
modes.append(this.renderSegment(label, state.mode === value, () => this.plugin.updateQueryPlan({ mode: value }, true)));
|
||
});
|
||
|
||
const queryActions = div("cva-query-actions");
|
||
const apply = makeButton("Apply Ask to query plan", "cva-button cva-ghost");
|
||
apply.addEventListener("click", () => this.plugin.applyAskToQueryPlan());
|
||
queryActions.append(apply);
|
||
query.append(modes, queryActions);
|
||
|
||
const dataview = div("cva-dataview-card");
|
||
dataview.append(sectionHead("table-2", "Query plan", "uses selected Scope folder"));
|
||
const plan = div("cva-query-plan");
|
||
const renderPlan = (queryText, askText = state.query) => {
|
||
const executable = this.plugin.getExecutableQueryPlan(queryText, askText, state.mode);
|
||
plan.empty();
|
||
[
|
||
["Search in", displayFolder(executable.folder)],
|
||
["Table columns", executable.columns],
|
||
["Ask terms", executable.askTerms.length ? executable.askTerms.join(", ") : "none"],
|
||
["Dataview terms", executable.dataviewTerms.length ? executable.dataviewTerms.join(", ") : "none"],
|
||
["Active terms", executable.activeTerms.length ? executable.activeTerms.join(", ") : "none yet"],
|
||
["Source", executable.source],
|
||
["Sort", executable.sort],
|
||
].forEach(([label, value]) => {
|
||
const row = div("cva-plan-row");
|
||
row.append(div("cva-plan-label", label), div("cva-plan-value", value));
|
||
plan.append(row);
|
||
});
|
||
if (executable.mismatch && state.mode !== "dataview") {
|
||
plan.append(div("cva-plan-warning", "Ask and Advanced Dataview are searching different terms. Hybrid will combine them; Apply Ask rewrites the Dataview query."));
|
||
}
|
||
};
|
||
dataview.append(plan);
|
||
|
||
const advanced = el("details", "cva-advanced");
|
||
const summary = el("summary", "cva-advanced-summary", "Advanced: Dataview query");
|
||
advanced.append(summary);
|
||
let code = el("textarea", "cva-code cva-code-input");
|
||
code.value = state.dataviewQuery;
|
||
code.addEventListener("input", () => {
|
||
this.plugin.updateQueryPlan({ dataviewQuery: code.value });
|
||
renderPlan(code.value, textarea.value);
|
||
});
|
||
code.addEventListener("change", () => this.plugin.updateQueryPlan({ dataviewQuery: code.value }, true));
|
||
advanced.append(code);
|
||
dataview.append(advanced);
|
||
renderPlan(state.dataviewQuery, state.query);
|
||
|
||
stage.append(query, dataview);
|
||
return stage;
|
||
}
|
||
|
||
renderSegment(label, active, onClick) {
|
||
const node = makeButton(label, `cva-segment ${active ? "active" : ""}`.trim());
|
||
node.addEventListener("click", onClick);
|
||
return node;
|
||
}
|
||
|
||
renderWorkbench() {
|
||
const workbench = div("cva-workbench");
|
||
workbench.append(this.renderScopePanel(), this.renderEvidencePanel());
|
||
return workbench;
|
||
}
|
||
|
||
renderScopePanel() {
|
||
const state = this.plugin.agentState;
|
||
const panel = div("cva-panel cva-scope-panel");
|
||
panel.append(sectionHead("folder-search", "2. Scope builder", "choose files first"));
|
||
|
||
const pickerRow = div("cva-picker-row");
|
||
const folderPicker = makeButton("", "cva-folder-picker");
|
||
folderPicker.append(icon("folder"), div("cva-folder-picker-copy", displayFolder(state.folder)));
|
||
folderPicker.addEventListener("click", () => this.plugin.openFolderPicker());
|
||
const choose = makeButton("Choose folder", "cva-button cva-ghost cva-folder-action");
|
||
choose.addEventListener("click", () => this.plugin.openFolderPicker());
|
||
pickerRow.append(folderPicker, choose);
|
||
panel.append(div("cva-field-label", "Folder scope"), pickerRow);
|
||
|
||
const rules = div("cva-run-rules");
|
||
const maxWrap = div("cva-run-rule");
|
||
const maxInput = el("input", "cva-run-input");
|
||
maxInput.type = "number";
|
||
maxInput.min = "5";
|
||
maxInput.max = "200";
|
||
maxInput.value = String(state.collection.maxEvidenceRows);
|
||
maxInput.addEventListener("change", () =>
|
||
this.plugin.updateCollectionOptions({ maxEvidenceRows: Math.max(5, Math.min(200, Number(maxInput.value) || DEFAULT_SETTINGS.maxEvidenceRows)) })
|
||
);
|
||
maxWrap.append(div("cva-field-label", "Current max rows"), maxInput);
|
||
|
||
const metadataWrap = div("cva-run-rule");
|
||
const metadataInput = el("input", "cva-run-input");
|
||
metadataInput.type = "text";
|
||
metadataInput.value = state.collection.metadataFields;
|
||
metadataInput.addEventListener("change", () => this.plugin.updateCollectionOptions({ metadataFields: metadataInput.value || DEFAULT_SETTINGS.metadataFields }));
|
||
metadataWrap.append(div("cva-field-label", "Current metadata fields"), metadataInput);
|
||
rules.append(maxWrap, metadataWrap);
|
||
panel.append(rules);
|
||
|
||
const group = div("cva-scope-group");
|
||
group.append(
|
||
this.renderScopeToggle("currentNote", "Current note", "Seed the query from the active editor"),
|
||
this.renderScopeToggle("backlinks", "Backlinks", "Pull notes that point to the active note"),
|
||
this.renderScopeToggle("outlinks", "Outlinks", "Pull notes linked from the active note"),
|
||
this.renderScopeToggle("selectedFolder", "Selected folder", displayFolder(state.folder), { locked: true }),
|
||
this.renderScopeToggle("includeSubfolders", "Subfolders", "Include nested notes inside the selected folder"),
|
||
this.renderScopeToggle("includeEveryday", "Everyday notes", "Include 00-03 human-managed notes"),
|
||
this.renderScopeToggle("includeArchive", "Archive", "Include archived notes")
|
||
);
|
||
panel.append(group);
|
||
|
||
const collectBar = div("cva-scope-collect");
|
||
const collectCopy = div("cva-scope-collect-copy");
|
||
collectCopy.append(div("cva-row-title", "Collect after query and scope are set"), div("cva-row-detail", "Build the evidence set from the current query, folder, toggles, and collection rules."));
|
||
const collect = makeButton(state.isCollecting ? "Collecting..." : "Run collection", "cva-button mod-cta");
|
||
collect.disabled = state.isCollecting;
|
||
collect.addEventListener("click", () => this.plugin.runCollection());
|
||
collectBar.append(collectCopy, collect);
|
||
panel.append(collectBar);
|
||
|
||
const files = div("cva-selected-files");
|
||
const selectedRows = this.plugin.includedRows();
|
||
const collectedRows = state.evidenceRows;
|
||
files.append(div("cva-subhead", "Selection summary"));
|
||
const summary = div("cva-selection-summary");
|
||
const metadataGaps = selectedRows.reduce((count, row) => count + (row.metadataGaps || []).length, 0);
|
||
const topFolders = Array.from(
|
||
selectedRows.reduce((map, row) => {
|
||
const folder = dirname(row.path) || "/";
|
||
map.set(folder, (map.get(folder) || 0) + 1);
|
||
return map;
|
||
}, new Map())
|
||
)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 2)
|
||
.map(([folder, count]) => `${folder} (${count})`);
|
||
[
|
||
["Selected", `${selectedRows.length} / ${collectedRows.length}`],
|
||
["Metadata gaps", String(metadataGaps)],
|
||
["Top folders", topFolders.length ? topFolders.join(" | ") : "none yet"],
|
||
].forEach(([label, value]) => {
|
||
const row = div("cva-selection-row");
|
||
row.append(div("cva-plan-label", label), div("cva-plan-value", value));
|
||
summary.append(row);
|
||
});
|
||
const summaryActions = div("cva-selection-actions");
|
||
const includeAll = makeButton("Include all collected", "cva-button cva-ghost cva-mini-action");
|
||
const excludeAll = makeButton("Exclude all collected", "cva-button cva-ghost cva-mini-action");
|
||
includeAll.disabled = !collectedRows.length;
|
||
excludeAll.disabled = !collectedRows.length;
|
||
includeAll.addEventListener("click", () => this.plugin.setEvidenceIncluded(collectedRows.map((row) => row.path), true));
|
||
excludeAll.addEventListener("click", () => this.plugin.setEvidenceIncluded(collectedRows.map((row) => row.path), false));
|
||
summaryActions.append(includeAll, excludeAll);
|
||
summary.append(summaryActions);
|
||
if (!collectedRows.length) {
|
||
summary.append(div("cva-empty", "Run collection to build a review set."));
|
||
}
|
||
files.append(summary);
|
||
panel.append(files);
|
||
return panel;
|
||
}
|
||
|
||
renderScopeToggle(key, label, detail, options = {}) {
|
||
const active = options.locked ? true : Boolean(this.plugin.agentState.scope[key]);
|
||
const row = makeButton("", `cva-scope-item ${active ? "active" : ""}`.trim());
|
||
const check = div("cva-scope-check");
|
||
setIcon(check, options.locked ? "lock" : active ? "check" : "minus");
|
||
const copy = div("cva-scope-copy");
|
||
copy.append(div("cva-row-title", label), div("cva-row-detail", detail));
|
||
row.append(check, copy);
|
||
if (options.locked) {
|
||
row.disabled = true;
|
||
row.addClass("locked");
|
||
row.title = "The selected folder is always included.";
|
||
} else {
|
||
row.addEventListener("click", () => this.plugin.toggleScope(key));
|
||
}
|
||
return row;
|
||
}
|
||
|
||
renderSelectedFile(row) {
|
||
const file = makeButton("", `cva-file-row ${row.included ? "" : "excluded"}`.trim());
|
||
file.title = row.path;
|
||
const marker = div(`cva-file-marker ${row.score >= 0.8 ? "strong" : row.reason.includes("outlink") || row.reason.includes("backlink") ? "bridge" : ""}`.trim());
|
||
const copy = div("cva-file-copy");
|
||
copy.append(div("cva-file-path", row.path), div("cva-file-reason", row.reason));
|
||
const include = div("cva-file-state", row.included ? "include" : "exclude");
|
||
file.append(marker, copy, include);
|
||
file.addEventListener("click", () => this.plugin.toggleEvidence(row.path));
|
||
return file;
|
||
}
|
||
|
||
renderEvidenceBulkActions(groups) {
|
||
const actions = div("cva-evidence-actions");
|
||
[
|
||
["Include all", () => this.plugin.setEvidenceIncluded(this.plugin.agentState.evidenceRows.map((row) => row.path), true)],
|
||
["Exclude all", () => this.plugin.setEvidenceIncluded(this.plugin.agentState.evidenceRows.map((row) => row.path), false)],
|
||
["Only strong", () => this.plugin.applyEvidencePreset("strong")],
|
||
["Only linked", () => this.plugin.applyEvidencePreset("linked")],
|
||
["Metadata complete", () => this.plugin.applyEvidencePreset("metadata-complete")],
|
||
].forEach(([label, onClick]) => {
|
||
const button = makeButton(label, "cva-button cva-ghost cva-evidence-action");
|
||
button.addEventListener("click", onClick);
|
||
actions.append(button);
|
||
});
|
||
const summary = div("cva-evidence-group-summary");
|
||
summary.textContent = groups.map((group) => `${group.label}: ${group.rows.length}`).join(" | ");
|
||
actions.append(summary);
|
||
return actions;
|
||
}
|
||
|
||
getEvidenceGroups(rows) {
|
||
const groups = [
|
||
{
|
||
id: "strong",
|
||
label: "Strong matches",
|
||
detail: "4+ query terms or high score",
|
||
rows: [],
|
||
test: (row) => (row.matchedTerms || []).length >= 4 || row.score >= 0.9,
|
||
},
|
||
{
|
||
id: "linked",
|
||
label: "Bridge matches",
|
||
detail: "current note, backlink, or outlink",
|
||
rows: [],
|
||
test: (row) => /current note|backlink|outlink/.test(row.reason),
|
||
},
|
||
{
|
||
id: "metadata-gaps",
|
||
label: "Metadata gaps",
|
||
detail: "selected candidates missing reviewed fields",
|
||
rows: [],
|
||
test: (row) => (row.metadataGaps || []).length > 0,
|
||
},
|
||
{
|
||
id: "low",
|
||
label: "Low confidence",
|
||
detail: "weak term overlap",
|
||
rows: [],
|
||
test: (row) => row.score < 0.78,
|
||
},
|
||
{
|
||
id: "folder",
|
||
label: "Folder matches",
|
||
detail: "remaining matches from selected folder",
|
||
rows: [],
|
||
test: () => true,
|
||
},
|
||
];
|
||
|
||
rows.forEach((row) => {
|
||
const group = groups.find((candidate) => candidate.test(row));
|
||
group.rows.push(row);
|
||
});
|
||
|
||
return groups.filter((group) => group.rows.length);
|
||
}
|
||
|
||
renderEvidencePanel() {
|
||
const state = this.plugin.agentState;
|
||
const panel = div("cva-panel cva-evidence-panel");
|
||
panel.append(sectionHead("list-filter", "3. Evidence review", state.lastRun ? `last run ${state.lastRun}` : "run collection first"));
|
||
|
||
const selected = this.plugin.includedRows();
|
||
const metadataGaps = selected.reduce((count, row) => count + (row.metadataGaps || []).length, 0);
|
||
const toolbar = div("cva-evidence-toolbar");
|
||
toolbar.append(
|
||
statusPill("Matched", String(state.evidenceRows.length), state.evidenceRows.length ? "ok" : ""),
|
||
statusPill("Selected", String(selected.length), selected.length ? "ok" : "warn"),
|
||
statusPill("Metadata gaps", String(metadataGaps), metadataGaps ? "warn" : "ok")
|
||
);
|
||
panel.append(toolbar);
|
||
|
||
if (!state.evidenceRows.length) {
|
||
const empty = div("cva-empty-state");
|
||
empty.append(
|
||
div("cva-empty-title", state.collectionMessage),
|
||
div("cva-empty-copy", "Run collection from Scope builder after the query and scope are ready."),
|
||
);
|
||
panel.append(empty);
|
||
} else {
|
||
const groups = this.getEvidenceGroups(state.evidenceRows);
|
||
panel.append(this.renderEvidenceBulkActions(groups));
|
||
const board = div("cva-evidence-board");
|
||
groups.forEach((group) => board.append(this.renderEvidenceGroup(group)));
|
||
panel.append(board);
|
||
}
|
||
|
||
const review = div("cva-review-note");
|
||
review.append(icon("info"), div("", "Uncheck noisy rows before previewing or creating the query report."));
|
||
panel.append(review);
|
||
return panel;
|
||
}
|
||
|
||
renderEvidenceGroup(group) {
|
||
const collapsed = Boolean(this.plugin.agentState.collapsedEvidenceGroups[group.id]);
|
||
const section = div(`cva-evidence-group ${collapsed ? "collapsed" : ""}`.trim());
|
||
const header = div("cva-evidence-group-head");
|
||
const toggle = makeButton("", "cva-evidence-group-toggle");
|
||
setIcon(toggle, collapsed ? "chevron-right" : "chevron-down");
|
||
toggle.addEventListener("click", () => this.plugin.toggleEvidenceGroup(group.id));
|
||
const copy = div("cva-evidence-group-copy");
|
||
const selectedCount = group.rows.filter((row) => row.included).length;
|
||
copy.append(div("cva-evidence-group-title", `${group.label} (${selectedCount}/${group.rows.length})`), div("cva-evidence-group-detail", group.detail));
|
||
const include = makeButton("Include all", "cva-button cva-ghost cva-mini-action");
|
||
const exclude = makeButton("Exclude all", "cva-button cva-ghost cva-mini-action");
|
||
const paths = group.rows.map((row) => row.path);
|
||
include.addEventListener("click", () => this.plugin.setEvidenceIncluded(paths, true));
|
||
exclude.addEventListener("click", () => this.plugin.setEvidenceIncluded(paths, false));
|
||
header.append(toggle, copy, include, exclude);
|
||
section.append(header);
|
||
|
||
if (!collapsed) {
|
||
const rows = div("cva-evidence-group-rows");
|
||
group.rows.forEach((row) => rows.append(this.renderEvidenceRow(row)));
|
||
section.append(rows);
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
renderEvidenceRow(row) {
|
||
const node = makeButton("", `cva-evidence-card ${row.included ? "" : "excluded"}`.trim());
|
||
const check = div("cva-evidence-check", row.included ? "✓" : "–");
|
||
const copy = div("cva-evidence-card-copy");
|
||
const head = div("cva-evidence-card-head");
|
||
head.append(div("cva-evidence-title", row.title), div("cva-evidence-score", row.score.toFixed(2)));
|
||
const meta = div("cva-evidence-meta");
|
||
meta.append(div("cva-evidence-folder", dirname(row.path)), div("cva-evidence-reason", row.reason));
|
||
const chips = div("cva-chip-row");
|
||
(row.matchedTerms || []).slice(0, 6).forEach((term) => chips.append(div("cva-chip", term)));
|
||
if ((row.metadataGaps || []).length) chips.append(div("cva-chip warn", `missing ${row.metadataGaps.join(", ")}`));
|
||
if ((row.tags || []).length) chips.append(div("cva-chip", `${row.tags.length} tags`));
|
||
if ((row.links || []).length) chips.append(div("cva-chip", `${row.links.length} links`));
|
||
copy.append(head, meta);
|
||
if (chips.children.length) copy.append(chips);
|
||
if (row.excerpt) copy.append(div("cva-evidence-excerpt", row.excerpt));
|
||
node.append(check, copy);
|
||
node.title = `${row.path}\n${row.excerpt}`;
|
||
node.addEventListener("click", () => this.plugin.toggleEvidence(row.path));
|
||
return node;
|
||
}
|
||
|
||
renderCompilePanel() {
|
||
const state = this.plugin.agentState;
|
||
const selectedCount = this.plugin.includedRows().length;
|
||
const panel = div(`cva-panel cva-compile-panel ${selectedCount ? "" : "cva-compile-muted"}`.trim());
|
||
panel.append(sectionHead("file-plus-2", "4. Compile note", selectedCount ? "final artifact" : "select evidence first"));
|
||
|
||
const pathBox = div("cva-path-box");
|
||
const outputRow = div("cva-output-path-row");
|
||
const outputInput = el("input", "cva-output-input");
|
||
outputInput.type = "text";
|
||
outputInput.value = state.outputPath;
|
||
outputInput.addEventListener("change", () => this.plugin.updateAgentState({ outputPath: normalizeOutputPath(outputInput.value) }, true));
|
||
const chooseOutput = makeButton("Choose folder", "cva-button cva-ghost cva-folder-action");
|
||
chooseOutput.addEventListener("click", () =>
|
||
this.plugin.openFolderPicker({
|
||
title: "Choose output folder",
|
||
onChoose: (path) => this.plugin.setOutputFolder(path),
|
||
})
|
||
);
|
||
outputRow.append(outputInput, chooseOutput);
|
||
pathBox.append(div("cva-field-label", "Output path"), outputRow);
|
||
panel.append(pathBox);
|
||
|
||
const typeBox = div("cva-report-type");
|
||
[
|
||
["Query report", "query-report"],
|
||
["Dashboard", "dashboard"],
|
||
["Link audit", "link-audit"],
|
||
["Metadata audit", "metadata-audit"],
|
||
].forEach(([label, value]) => {
|
||
typeBox.append(this.renderSegment(label, state.reportType === value, () => this.plugin.setReportType(value)));
|
||
});
|
||
panel.append(typeBox);
|
||
|
||
const sections = div("cva-output-sections");
|
||
sections.append(div("cva-subhead", "Report sections"));
|
||
[
|
||
["frontmatter", "Data query frontmatter"],
|
||
["dataview", "Dataview block"],
|
||
["files", "Files reviewed"],
|
||
["evidence", "Evidence table"],
|
||
["synthesis", "Codex synthesis"],
|
||
["links", "Suggested links"],
|
||
["metadata", "Metadata gaps"],
|
||
].forEach(([key, label]) => sections.append(this.renderOutputSection(key, label)));
|
||
panel.append(sections);
|
||
|
||
panel.append(this.renderCodexSynthesisPanel(selectedCount));
|
||
|
||
const preview = div("cva-note-preview");
|
||
const report = REPORT_TYPES[state.reportType] || REPORT_TYPES["query-report"];
|
||
preview.append(div("cva-note-preview-title", `# ${this.plugin.outputTitle()}`));
|
||
preview.append(div("cva-note-preview-line", report.label));
|
||
preview.append(div("cva-note-preview-line muted", `${report.purpose} | files_reviewed: ${selectedCount}`));
|
||
panel.append(preview);
|
||
return panel;
|
||
}
|
||
|
||
renderCodexSynthesisPanel(selectedCount) {
|
||
const synthesis = this.plugin.agentState.codexSynthesis || createInitialSynthesisState();
|
||
const connected = this.plugin.codexState.state === "connected";
|
||
const running = synthesis.status === "running";
|
||
const box = div(`cva-synthesis-box ${synthesis.status}`.trim());
|
||
const head = div("cva-synthesis-head");
|
||
const title = div("cva-synthesis-title");
|
||
title.append(icon("sparkles"), div("cva-row-title", "Codex synthesis"));
|
||
head.append(title, div("cva-panel-meta", this.plugin.synthesisStatusLabel()));
|
||
box.append(head);
|
||
|
||
const guard = div("cva-synthesis-guard");
|
||
[
|
||
["Input", selectedCount ? `${selectedCount} reviewed evidence row(s)` : "select evidence first"],
|
||
["Boundary", "selected evidence only; no silent note edits"],
|
||
["Output", `${REPORT_TYPES[this.plugin.agentState.reportType]?.label || "Query report"} markdown section`],
|
||
].forEach(([label, value]) => {
|
||
const row = div("cva-plan-row");
|
||
row.append(div("cva-plan-label", label), div("cva-plan-value", value));
|
||
guard.append(row);
|
||
});
|
||
box.append(guard);
|
||
|
||
if (synthesis.error) box.append(div("cva-synthesis-error", synthesis.error));
|
||
if (synthesis.markdown) {
|
||
const output = div("cva-synthesis-output");
|
||
output.textContent = synthesis.markdown;
|
||
box.append(output);
|
||
} else {
|
||
box.append(div("cva-synthesis-empty", synthesis.detail || "Connect Codex, review evidence, then run synthesis."));
|
||
}
|
||
|
||
const actions = div("cva-synthesis-actions");
|
||
const run = makeButton(running ? "Synthesizing..." : "Run Codex synthesis", "cva-button mod-cta");
|
||
const clear = makeButton("Clear synthesis", "cva-button cva-ghost");
|
||
run.disabled = running || !connected || !selectedCount;
|
||
clear.disabled = running || (!synthesis.markdown && !synthesis.error);
|
||
run.addEventListener("click", () => this.plugin.runCodexSynthesis());
|
||
clear.addEventListener("click", () => this.plugin.clearCodexSynthesis());
|
||
actions.append(run, clear);
|
||
box.append(actions);
|
||
return box;
|
||
}
|
||
|
||
renderOutputSection(key, label) {
|
||
const active = Boolean(this.plugin.agentState.sections[key]);
|
||
const row = makeButton("", `cva-output-section ${active ? "active" : ""}`.trim());
|
||
const mark = div("cva-output-mark");
|
||
setIcon(mark, active ? "check" : "minus");
|
||
row.append(mark, div("cva-output-label", label));
|
||
row.addEventListener("click", () => this.plugin.toggleSection(key));
|
||
return row;
|
||
}
|
||
|
||
renderCompileStrip() {
|
||
const state = this.plugin.agentState;
|
||
const selectedCount = this.plugin.includedRows().length;
|
||
const strip = div("cva-compile-strip");
|
||
const left = div("cva-compile-summary");
|
||
const summary = selectedCount
|
||
? `${selectedCount} selected note(s) ready for ${REPORT_TYPES[state.reportType]?.label || "Query report"}.`
|
||
: state.evidenceRows.length
|
||
? "Select at least one evidence row before creating a report."
|
||
: "Run collection and select evidence before creating a report.";
|
||
left.append(icon("database-zap"), div("cva-compile-copy", summary));
|
||
|
||
const actions = div("cva-compile-actions");
|
||
const preview = makeButton("Preview note", "cva-button mod-cta");
|
||
const create = makeButton("Create after approval", "cva-button cva-ghost");
|
||
const synth = makeButton("Run Codex synthesis", "cva-button cva-ghost");
|
||
preview.disabled = !selectedCount;
|
||
create.disabled = !selectedCount;
|
||
synth.disabled = !selectedCount || state.codexSynthesis?.status === "running" || this.plugin.codexState.state !== "connected";
|
||
preview.addEventListener("click", () => this.plugin.previewCompiledNote());
|
||
create.addEventListener("click", async () => this.plugin.createCompiledNote(this.plugin.compileMarkdown()));
|
||
synth.addEventListener("click", () => this.plugin.runCodexSynthesis());
|
||
actions.append(synth, preview, create);
|
||
|
||
if (state.collectionMessage) strip.title = state.collectionMessage;
|
||
strip.append(left, actions);
|
||
return strip;
|
||
}
|
||
}
|
||
|
||
class CodexVaultAgentSettingTab extends PluginSettingTab {
|
||
constructor(app, plugin) {
|
||
super(app, plugin);
|
||
this.plugin = plugin;
|
||
}
|
||
|
||
display() {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
containerEl.createEl("h2", { text: "Codex Vault Agent" });
|
||
|
||
new Setting(containerEl)
|
||
.setName("Default folder")
|
||
.setDesc("Starting folder for new query runs. Leave blank to search the entire vault.")
|
||
.addText((text) =>
|
||
text
|
||
.setPlaceholder("Entire vault")
|
||
.setValue(this.plugin.settings.defaultFolder || "")
|
||
.onChange(async (value) => {
|
||
await this.plugin.updateSettings({ defaultFolder: value.trim() });
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Output folder")
|
||
.setDesc("Folder where compiled report notes are created.")
|
||
.addText((text) =>
|
||
text
|
||
.setPlaceholder(DEFAULT_OUTPUT_FOLDER)
|
||
.setValue(this.plugin.settings.outputFolder || DEFAULT_OUTPUT_FOLDER)
|
||
.onChange(async (value) => {
|
||
await this.plugin.updateSettings({ outputFolder: value.trim() || DEFAULT_OUTPUT_FOLDER });
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Default max evidence rows")
|
||
.setDesc("Starting row limit for new collection runs.")
|
||
.addText((text) =>
|
||
text
|
||
.setPlaceholder(String(DEFAULT_SETTINGS.maxEvidenceRows))
|
||
.setValue(String(this.plugin.settings.maxEvidenceRows || DEFAULT_SETTINGS.maxEvidenceRows))
|
||
.onChange(async (value) => {
|
||
const next = Math.max(5, Math.min(200, Number(value) || DEFAULT_SETTINGS.maxEvidenceRows));
|
||
await this.plugin.updateSettings({ maxEvidenceRows: next });
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Default metadata fields")
|
||
.setDesc("Comma-separated frontmatter fields checked for metadata gaps in new runs.")
|
||
.addText((text) =>
|
||
text
|
||
.setPlaceholder(DEFAULT_SETTINGS.metadataFields)
|
||
.setValue(this.plugin.settings.metadataFields || DEFAULT_SETTINGS.metadataFields)
|
||
.onChange(async (value) => {
|
||
await this.plugin.updateSettings({ metadataFields: value.trim() || DEFAULT_SETTINGS.metadataFields });
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Default subfolders")
|
||
.setDesc("Starting subfolder behavior for new runs. Current-run scope remains controlled in Vault Agent.")
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(Boolean(this.plugin.settings.includeSubfolders)).onChange(async (value) => {
|
||
await this.plugin.updateSettings({ includeSubfolders: value });
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Enable Codex synthesis")
|
||
.setDesc("When disabled, collection and report compilation still work locally without Codex.")
|
||
.addToggle((toggle) =>
|
||
toggle.setValue(Boolean(this.plugin.settings.codexEnabled)).onChange(async (value) => {
|
||
await this.plugin.updateSettings({ codexEnabled: value });
|
||
if (!value) this.plugin.disconnectCodex({ silent: true });
|
||
else {
|
||
this.plugin.setCodexState({
|
||
state: "disconnected",
|
||
label: "Not connected",
|
||
tone: "warn",
|
||
mode: "Preview shell",
|
||
modeTone: "",
|
||
detail: "Connect starts the local handshake. Synthesis later sends selected reviewed evidence only.",
|
||
});
|
||
}
|
||
this.plugin.refreshViews();
|
||
})
|
||
);
|
||
|
||
new Setting(containerEl)
|
||
.setName("Codex executable")
|
||
.setDesc("Path or command used to start `codex app-server` with your existing Codex login.")
|
||
.addText((text) =>
|
||
text
|
||
.setPlaceholder(DEFAULT_CODEX_BIN)
|
||
.setValue(this.plugin.settings.codexBin || DEFAULT_CODEX_BIN)
|
||
.onChange(async (value) => {
|
||
await this.plugin.updateSettings({ codexBin: value.trim() || DEFAULT_CODEX_BIN });
|
||
})
|
||
);
|
||
}
|
||
}
|
||
|
||
module.exports = class CodexVaultAgentPlugin extends Plugin {
|
||
async onload() {
|
||
this.views = new Set();
|
||
this.codexClient = null;
|
||
this.codexUnsubscribe = null;
|
||
this.activeCodexRun = null;
|
||
this.synthesisRefreshTimer = null;
|
||
const data = await this.loadSettings();
|
||
const initialState = createInitialAgentState(this.settings);
|
||
this.agentState = Object.assign(initialState, data?.agentState || {});
|
||
this.agentState.scope = Object.assign(initialState.scope, this.agentState.scope || {});
|
||
this.agentState.sections = Object.assign(initialState.sections, this.agentState.sections || {});
|
||
this.agentState.collapsedEvidenceGroups = Object.assign({}, this.agentState.collapsedEvidenceGroups || {});
|
||
this.agentState.codexSynthesis = Object.assign(createInitialSynthesisState(), this.agentState.codexSynthesis || {});
|
||
this.agentState.collection = Object.assign(
|
||
{
|
||
maxEvidenceRows: this.settings.maxEvidenceRows || DEFAULT_SETTINGS.maxEvidenceRows,
|
||
metadataFields: this.settings.metadataFields || DEFAULT_SETTINGS.metadataFields,
|
||
},
|
||
this.agentState.collection || {}
|
||
);
|
||
this.agentState.evidenceRows = [];
|
||
this.agentState.isCollecting = false;
|
||
this.codexState = {
|
||
state: "disconnected",
|
||
label: this.settings.codexEnabled ? "Not connected" : "Disabled",
|
||
tone: "warn",
|
||
mode: "Preview shell",
|
||
modeTone: "",
|
||
detail: this.settings.codexEnabled
|
||
? "Connect starts the local handshake. Synthesis later sends selected reviewed evidence only."
|
||
: "Codex synthesis is disabled in plugin settings. Collection and report compilation still work locally.",
|
||
};
|
||
|
||
this.registerView(VIEW_TYPE, (leaf) => new CodexVaultAgentPreviewView(leaf, this));
|
||
this.addSettingTab(new CodexVaultAgentSettingTab(this.app, this));
|
||
this.addRibbonIcon("database-zap", "Open Vault Agent", () => this.activateView());
|
||
this.addCommand({
|
||
id: "open-codex-vault-agent",
|
||
name: "Open Vault Agent",
|
||
callback: () => this.activateView(),
|
||
});
|
||
}
|
||
|
||
async onunload() {
|
||
if (this.synthesisRefreshTimer) window.clearTimeout(this.synthesisRefreshTimer);
|
||
if (this.activeCodexRun?.timeout) window.clearTimeout(this.activeCodexRun.timeout);
|
||
this.disconnectCodex({ silent: true });
|
||
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
|
||
}
|
||
|
||
refreshViews() {
|
||
for (const view of this.views) view.render();
|
||
}
|
||
|
||
async loadSettings() {
|
||
const data = (await this.loadData()) || {};
|
||
this.settings = Object.assign({}, DEFAULT_SETTINGS, data.settings || {});
|
||
return data;
|
||
}
|
||
|
||
async savePluginData(agentState = this.agentState) {
|
||
await this.saveData({
|
||
settings: this.settings,
|
||
agentState,
|
||
});
|
||
}
|
||
|
||
async updateSettings(patch) {
|
||
this.settings = Object.assign({}, this.settings || DEFAULT_SETTINGS, patch);
|
||
await this.savePluginData();
|
||
}
|
||
|
||
updateAgentState(patch, refresh = false) {
|
||
this.agentState = Object.assign({}, this.agentState, patch);
|
||
if (refresh) this.refreshViews();
|
||
}
|
||
|
||
resetCodexSynthesis(detail = "Run after reviewing evidence. Codex receives selected rows only.") {
|
||
this.agentState.codexSynthesis = Object.assign(createInitialSynthesisState(), { detail });
|
||
}
|
||
|
||
markCodexSynthesisStale(detail) {
|
||
const current = this.agentState.codexSynthesis || createInitialSynthesisState();
|
||
if (!current.markdown && !current.error) {
|
||
this.resetCodexSynthesis(detail);
|
||
return;
|
||
}
|
||
|
||
this.agentState.codexSynthesis = Object.assign({}, current, {
|
||
status: "stale",
|
||
detail,
|
||
});
|
||
}
|
||
|
||
clearCodexSynthesis() {
|
||
this.resetCodexSynthesis();
|
||
this.refreshViews();
|
||
}
|
||
|
||
scheduleSynthesisRefresh() {
|
||
if (this.synthesisRefreshTimer) return;
|
||
this.synthesisRefreshTimer = window.setTimeout(() => {
|
||
this.synthesisRefreshTimer = null;
|
||
this.refreshViews();
|
||
}, 180);
|
||
}
|
||
|
||
toggleScope(key) {
|
||
this.agentState.scope[key] = !this.agentState.scope[key];
|
||
this.agentState.evidenceRows = [];
|
||
this.agentState.collectionMessage = "Scope changed. Run collection to rebuild the evidence set.";
|
||
this.resetCodexSynthesis("Scope changed. Recollect evidence before running Codex.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
updateQueryPlan(patch, refresh = false) {
|
||
this.agentState = Object.assign({}, this.agentState, patch, {
|
||
evidenceRows: [],
|
||
collapsedEvidenceGroups: {},
|
||
lastRun: null,
|
||
collectionMessage: "Query plan changed. Run collection to rebuild the evidence set.",
|
||
});
|
||
this.resetCodexSynthesis("Query plan changed. Recollect evidence before running Codex.");
|
||
if (refresh) this.refreshViews();
|
||
}
|
||
|
||
updateCollectionOptions(patch) {
|
||
this.agentState.collection = Object.assign({}, this.agentState.collection, patch);
|
||
this.agentState.evidenceRows = [];
|
||
this.agentState.collectionMessage = "Collection rules changed. Run collection to rebuild the evidence set.";
|
||
this.resetCodexSynthesis("Collection rules changed. Recollect evidence before running Codex.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
toggleEvidence(path) {
|
||
this.agentState.evidenceRows = this.agentState.evidenceRows.map((row) =>
|
||
row.path === path ? Object.assign({}, row, { included: !row.included }) : row
|
||
);
|
||
this.markCodexSynthesisStale("Evidence selection changed. Run Codex synthesis again before compiling.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
setEvidenceIncluded(paths, included) {
|
||
const selectedPaths = new Set(paths);
|
||
this.agentState.evidenceRows = this.agentState.evidenceRows.map((row) =>
|
||
selectedPaths.has(row.path) ? Object.assign({}, row, { included }) : row
|
||
);
|
||
this.markCodexSynthesisStale("Evidence selection changed. Run Codex synthesis again before compiling.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
applyEvidencePreset(preset) {
|
||
this.agentState.evidenceRows = this.agentState.evidenceRows.map((row) => {
|
||
let included = row.included;
|
||
if (preset === "strong") included = (row.matchedTerms || []).length >= 4 || row.score >= 0.9;
|
||
if (preset === "linked") included = /current note|backlink|outlink/.test(row.reason);
|
||
if (preset === "metadata-complete") included = (row.metadataGaps || []).length === 0;
|
||
return Object.assign({}, row, { included });
|
||
});
|
||
this.markCodexSynthesisStale("Evidence preset changed. Run Codex synthesis again before compiling.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
toggleEvidenceGroup(groupId) {
|
||
this.agentState.collapsedEvidenceGroups = Object.assign({}, this.agentState.collapsedEvidenceGroups, {
|
||
[groupId]: !this.agentState.collapsedEvidenceGroups[groupId],
|
||
});
|
||
this.refreshViews();
|
||
}
|
||
|
||
toggleSection(key) {
|
||
this.agentState.sections[key] = !this.agentState.sections[key];
|
||
this.refreshViews();
|
||
}
|
||
|
||
setReportType(reportType) {
|
||
if (this.agentState.reportType === reportType) return;
|
||
this.agentState.reportType = reportType;
|
||
this.markCodexSynthesisStale("Report type changed. Run Codex synthesis again for this output.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
includedRows() {
|
||
return this.agentState.evidenceRows.filter((row) => row.included);
|
||
}
|
||
|
||
outputTitle() {
|
||
return titleFromPath(normalizeOutputPath(this.agentState.outputPath));
|
||
}
|
||
|
||
async saveCurrentState() {
|
||
const agentState = Object.assign({}, this.agentState, {
|
||
evidenceRows: [],
|
||
isCollecting: false,
|
||
codexSynthesis: createInitialSynthesisState(),
|
||
});
|
||
await this.savePluginData(agentState);
|
||
new Notice("Vault Agent query settings saved.");
|
||
}
|
||
|
||
openFolderPicker(options = {}) {
|
||
new FolderPickerModal(this.app, this, options).open();
|
||
}
|
||
|
||
getVaultFolders() {
|
||
return this.app.vault
|
||
.getAllLoadedFiles()
|
||
.filter((file) => typeof file.path === "string" && file.path && typeof file.children !== "undefined")
|
||
.map((folder) => folder.path)
|
||
.sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
setFolderScope(folderPath) {
|
||
const nextFolder = String(folderPath || "").trim();
|
||
|
||
this.agentState = Object.assign({}, this.agentState, {
|
||
folder: nextFolder,
|
||
dataviewQuery: rewriteDataviewFolder(this.agentState.dataviewQuery, nextFolder),
|
||
evidenceRows: [],
|
||
collectionMessage: "Folder changed. Run collection to rebuild the evidence set.",
|
||
});
|
||
this.resetCodexSynthesis("Folder changed. Recollect evidence before running Codex.");
|
||
this.refreshViews();
|
||
}
|
||
|
||
setOutputFolder(folderPath) {
|
||
const outputFolder =
|
||
typeof folderPath === "string" ? folderPath.trim() : String(dirname(this.agentState.outputPath) || DEFAULT_SETTINGS.outputFolder).trim();
|
||
const fileName = `${this.outputTitle()}.md`;
|
||
this.agentState = Object.assign({}, this.agentState, {
|
||
outputPath: outputFolder ? `${outputFolder}/${fileName}` : fileName,
|
||
});
|
||
this.refreshViews();
|
||
}
|
||
|
||
getExecutableQueryPlan(queryText = this.agentState.dataviewQuery, askText = this.agentState.query, mode = this.agentState.mode) {
|
||
const parsed = parseDataviewPlan(queryText, this.agentState.folder);
|
||
const exactPhrases = extractAskPhrases(askText);
|
||
const askTerms = uniqueTerms([...exactPhrases, ...tokenize(askText)], 12);
|
||
const dataviewTerms = uniqueTerms(parsed.terms, 12);
|
||
let activeTerms = [];
|
||
let source = "";
|
||
|
||
if (mode === "dataview") {
|
||
activeTerms = dataviewTerms;
|
||
source = "Advanced Dataview only";
|
||
} else if (mode === "exact") {
|
||
activeTerms = exactPhrases.length ? exactPhrases : askTerms;
|
||
source = "Exact Ask phrases";
|
||
} else if (mode === "semantic") {
|
||
activeTerms = askTerms;
|
||
source = "Ask concepts";
|
||
} else {
|
||
activeTerms = uniqueTerms([...askTerms, ...dataviewTerms], 18);
|
||
source = dataviewTerms.length ? "Ask + Advanced Dataview" : "Ask concepts";
|
||
}
|
||
|
||
const mismatch = askTerms.length > 0 && dataviewTerms.length > 0 && !termsOverlap(askTerms, dataviewTerms);
|
||
return {
|
||
mode,
|
||
source,
|
||
folder: this.agentState.folder,
|
||
folderLabel: displayFolder(this.agentState.folder),
|
||
columns: parsed.columns,
|
||
sort: parsed.sort,
|
||
askTerms,
|
||
exactPhrases,
|
||
dataviewTerms,
|
||
activeTerms,
|
||
mismatch,
|
||
};
|
||
}
|
||
|
||
applyAskToQueryPlan() {
|
||
const plan = this.getExecutableQueryPlan(this.agentState.dataviewQuery, this.agentState.query, this.agentState.mode);
|
||
const terms = this.agentState.mode === "exact" ? plan.exactPhrases : plan.askTerms;
|
||
const dataviewQuery = buildDataviewQueryFromTerms(this.agentState.dataviewQuery, this.agentState.folder, terms.length ? terms : plan.activeTerms);
|
||
this.updateQueryPlan({ dataviewQuery }, true);
|
||
new Notice("Ask applied to the Advanced Dataview query.");
|
||
}
|
||
|
||
async runCollection() {
|
||
if (this.agentState.isCollecting) return;
|
||
this.updateAgentState({ isCollecting: true, collectionMessage: "Collecting evidence from local vault metadata..." }, true);
|
||
|
||
try {
|
||
const rows = await this.collectEvidence();
|
||
const activeTerms = this.getActiveSearchTerms();
|
||
this.updateAgentState(
|
||
{
|
||
evidenceRows: rows,
|
||
codexSynthesis: createInitialSynthesisState(),
|
||
isCollecting: false,
|
||
lastRun: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||
collectionMessage: rows.length
|
||
? `Collected ${rows.length} candidate note(s).`
|
||
: `No matches found for: ${activeTerms.length ? activeTerms.join(", ") : "the current query"}.`,
|
||
},
|
||
true
|
||
);
|
||
new Notice(`Collected ${rows.length} candidate note(s).`);
|
||
} catch (error) {
|
||
this.updateAgentState({ isCollecting: false, collectionMessage: error.message || String(error) }, true);
|
||
new Notice("Collection failed. See the evidence panel for details.");
|
||
}
|
||
}
|
||
|
||
getActiveSearchTerms() {
|
||
return this.getExecutableQueryPlan().activeTerms;
|
||
}
|
||
|
||
async collectEvidence() {
|
||
const candidates = this.collectCandidateFiles();
|
||
const terms = this.getActiveSearchTerms();
|
||
const dataviewApi = this.app.plugins.plugins.dataview?.api;
|
||
const rows = [];
|
||
|
||
for (const [path, reasons] of candidates.entries()) {
|
||
const file = this.app.vault.getAbstractFileByPath(path);
|
||
if (!file || file.extension !== "md") continue;
|
||
|
||
const content = await this.app.vault.cachedRead(file);
|
||
const cache = this.app.metadataCache.getFileCache(file) || {};
|
||
const frontmatter = cache.frontmatter || {};
|
||
const dataviewPage = dataviewApi ? this.safeDataviewPage(dataviewApi, file.path) : null;
|
||
const metadataText = `${safeStringify(frontmatter)} ${safeStringify(dataviewPage)}`;
|
||
const haystack = `${file.path}\n${content}\n${metadataText}`.toLowerCase();
|
||
const matchedTerms = terms.filter((term) => termMatchesText(haystack, term));
|
||
|
||
if (!matchedTerms.length && !reasons.some((reason) => reason !== "selected folder")) continue;
|
||
|
||
const metadataFields = parseCsv(this.agentState.collection.metadataFields, ["type", "status", "topic"]);
|
||
const metadataGaps = metadataFields.filter((field) => typeof frontmatter[field] === "undefined");
|
||
const score = Math.min(
|
||
0.99,
|
||
0.24 +
|
||
matchedTerms.length * 0.075 +
|
||
(reasons.includes("current note") ? 0.18 : 0) +
|
||
(reasons.includes("backlink") ? 0.13 : 0) +
|
||
(reasons.includes("outlink") ? 0.13 : 0) +
|
||
(this.agentState.folder && file.path.startsWith(this.agentState.folder) ? 0.08 : 0)
|
||
);
|
||
|
||
rows.push({
|
||
path: file.path,
|
||
title: titleFromPath(file.path),
|
||
reason: this.formatReason(reasons, matchedTerms),
|
||
score,
|
||
included: true,
|
||
matchedTerms,
|
||
metadataGaps,
|
||
excerpt: this.extractExcerpt(content, matchedTerms),
|
||
tags: (cache.tags || []).map((tag) => tag.tag),
|
||
links: (cache.links || []).map((link) => link.link).slice(0, 12),
|
||
});
|
||
}
|
||
|
||
return rows
|
||
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
||
.slice(0, this.agentState.collection.maxEvidenceRows);
|
||
}
|
||
|
||
collectCandidateFiles() {
|
||
const candidates = new Map();
|
||
const add = (file, reason) => {
|
||
if (!file || file.extension !== "md") return;
|
||
if (!candidates.has(file.path)) candidates.set(file.path, []);
|
||
if (!candidates.get(file.path).includes(reason)) candidates.get(file.path).push(reason);
|
||
};
|
||
|
||
const files = this.app.vault.getMarkdownFiles();
|
||
const active = this.app.workspace.getActiveFile();
|
||
const scope = this.agentState.scope;
|
||
|
||
for (const file of files) {
|
||
if (folderContainsPath(this.agentState.folder, file.path, scope.includeSubfolders)) add(file, "selected folder");
|
||
}
|
||
|
||
if (scope.includeEveryday) {
|
||
for (const file of files) {
|
||
if (/^(00 Inbox|00 Notes|01 Daily|02 Areas|03 Projects)\//.test(file.path)) add(file, "everyday scope");
|
||
}
|
||
}
|
||
|
||
if (scope.includeArchive) {
|
||
for (const file of files) {
|
||
if (file.path.startsWith("99 Archive/")) add(file, "archive scope");
|
||
}
|
||
}
|
||
|
||
if (scope.currentNote && active) add(active, "current note");
|
||
|
||
if (active && (scope.backlinks || scope.outlinks)) {
|
||
const activeCache = this.app.metadataCache.getFileCache(active) || {};
|
||
|
||
if (scope.outlinks) {
|
||
for (const link of activeCache.links || []) {
|
||
const linked = this.app.metadataCache.getFirstLinkpathDest(link.link, active.path);
|
||
add(linked, "outlink");
|
||
}
|
||
}
|
||
|
||
if (scope.backlinks) {
|
||
const backlinks = this.app.metadataCache.getBacklinksForFile(active);
|
||
const backlinkPaths = backlinks?.data instanceof Map ? Array.from(backlinks.data.keys()) : Object.keys(backlinks?.data || {});
|
||
for (const path of backlinkPaths) add(this.app.vault.getAbstractFileByPath(path), "backlink");
|
||
}
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
|
||
safeDataviewPage(api, path) {
|
||
try {
|
||
return api.page(path);
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
formatReason(reasons, terms) {
|
||
const parts = reasons.filter((reason) => reason !== "selected folder");
|
||
if (terms.length) parts.push(`matched ${terms.slice(0, 4).join(", ")}`);
|
||
return parts.length ? parts.join(" + ") : "selected folder";
|
||
}
|
||
|
||
extractExcerpt(content, terms) {
|
||
const compact = content.replace(/\s+/g, " ").trim();
|
||
if (!compact) return "";
|
||
|
||
const lower = compact.toLowerCase();
|
||
const index = terms.map((term) => lower.indexOf(term.toLowerCase())).filter((value) => value >= 0).sort((a, b) => a - b)[0] || 0;
|
||
const start = Math.max(0, index - 90);
|
||
return `${start > 0 ? "..." : ""}${compact.slice(start, start + 260)}${start + 260 < compact.length ? "..." : ""}`;
|
||
}
|
||
|
||
compileMarkdown() {
|
||
const state = this.agentState;
|
||
const selected = this.includedRows();
|
||
const generated = new Date().toISOString().slice(0, 10);
|
||
const report = REPORT_TYPES[state.reportType] || REPORT_TYPES["query-report"];
|
||
const rowsWithGaps = selected.filter((row) => (row.metadataGaps || []).length);
|
||
const linkCandidates = Array.from(new Set(selected.flatMap((row) => row.links || []))).slice(0, 30);
|
||
const strongRows = selected.filter((row) => (row.matchedTerms || []).length >= 4 || row.score >= 0.9);
|
||
const linkedRows = selected.filter((row) => /current note|backlink|outlink/.test(row.reason));
|
||
const synthesis = state.codexSynthesis || createInitialSynthesisState();
|
||
const hasCodexSynthesis = synthesis.status === "complete" && synthesis.markdown.trim();
|
||
const folderCounts = Array.from(
|
||
selected.reduce((map, row) => {
|
||
const folder = dirname(row.path) || "/";
|
||
map.set(folder, (map.get(folder) || 0) + 1);
|
||
return map;
|
||
}, new Map())
|
||
).sort((a, b) => b[1] - a[1]);
|
||
const metadataFields = parseCsv(state.collection.metadataFields, ["type", "status", "topic"]);
|
||
const executable = this.getExecutableQueryPlan();
|
||
const lines = [];
|
||
const pushScope = () => {
|
||
lines.push("## Scope", "");
|
||
lines.push(`- Folder: \`${displayFolder(state.folder)}\``);
|
||
lines.push(`- Mode: \`${state.mode}\``);
|
||
lines.push(`- Query source: ${executable.source}`);
|
||
lines.push(`- Active terms: ${executable.activeTerms.length ? executable.activeTerms.map((term) => `\`${term}\``).join(", ") : "none"}`);
|
||
lines.push("- Selected folder: included");
|
||
lines.push(`- Current note: ${state.scope.currentNote ? "included" : "excluded"}`);
|
||
lines.push(`- Backlinks: ${state.scope.backlinks ? "included" : "excluded"}`);
|
||
lines.push(`- Outlinks: ${state.scope.outlinks ? "included" : "excluded"}`);
|
||
lines.push(`- Folder subfolders: ${state.scope.includeSubfolders ? "included" : "direct children only"}`);
|
||
lines.push(`- Max evidence rows: ${state.collection.maxEvidenceRows}`);
|
||
lines.push(`- Metadata fields: \`${state.collection.metadataFields}\``, "");
|
||
};
|
||
const pushDataview = () => {
|
||
if (state.sections.dataview) lines.push("## Data Query", "", "```dataview", state.dataviewQuery.trim(), "```", "");
|
||
};
|
||
const pushEvidenceTable = (heading = "Evidence Table") => {
|
||
if (!state.sections.evidence) return;
|
||
lines.push(`## ${heading}`, "");
|
||
lines.push("| Note | Reason | Score | Matched terms |");
|
||
lines.push("| --- | --- | ---: | --- |");
|
||
selected.forEach((row) =>
|
||
lines.push(
|
||
`| ${wikilink(row.path)} | ${row.reason.replace(/\|/g, "/")} | ${row.score.toFixed(2)} | ${(row.matchedTerms || []).join(", ")} |`
|
||
)
|
||
);
|
||
lines.push("");
|
||
};
|
||
const pushFiles = () => {
|
||
if (!state.sections.files) return;
|
||
lines.push("## Files Reviewed", "");
|
||
if (!selected.length) lines.push("- No files selected.");
|
||
else selected.forEach((row) => lines.push(`- ${wikilink(row.path)} — ${row.reason}`));
|
||
lines.push("");
|
||
};
|
||
const pushMetadataGaps = () => {
|
||
if (!state.sections.metadata) return;
|
||
lines.push("## Metadata Gaps", "");
|
||
if (!rowsWithGaps.length) lines.push("- No metadata gaps detected for selected files.");
|
||
else rowsWithGaps.forEach((row) => lines.push(`- ${wikilink(row.path)}: missing \`${(row.metadataGaps || []).join("`, `")}\``));
|
||
lines.push("");
|
||
};
|
||
const pushSuggestedLinks = () => {
|
||
if (!state.sections.links) return;
|
||
lines.push("## Suggested Links", "");
|
||
if (!linkCandidates.length) lines.push("- No link candidates collected.");
|
||
else linkCandidates.forEach((link) => lines.push(`- [[${link}]]`));
|
||
lines.push("");
|
||
};
|
||
const pushCodexSynthesis = (heading = "Codex Synthesis") => {
|
||
if (!state.sections.synthesis) return;
|
||
lines.push(`## ${heading}`, "");
|
||
if (hasCodexSynthesis) {
|
||
lines.push(`_Generated from ${synthesis.selectedCount || selected.length} reviewed evidence row(s); no vault edits were applied._`, "");
|
||
lines.push(synthesis.markdown.trim(), "");
|
||
} else {
|
||
lines.push("> Codex synthesis has not been run yet. This deterministic report records the reviewed evidence set and query field.", "");
|
||
}
|
||
};
|
||
|
||
if (state.sections.frontmatter) {
|
||
lines.push("---");
|
||
lines.push(`type: ${state.reportType}`);
|
||
lines.push(`query: ${yamlScalar(state.query)}`);
|
||
lines.push(`query_mode: ${state.mode}`);
|
||
lines.push(`generated: ${generated}`);
|
||
lines.push(`files_reviewed: ${selected.length}`);
|
||
lines.push(`report_purpose: ${yamlScalar(report.purpose)}`);
|
||
lines.push("agent: codex-vault-agent");
|
||
lines.push("---", "");
|
||
}
|
||
|
||
lines.push(`# ${this.outputTitle()}`, "");
|
||
lines.push(`> ${report.purpose}`, "");
|
||
|
||
if (state.reportType === "dashboard") {
|
||
pushScope();
|
||
lines.push("## Dashboard Summary", "");
|
||
lines.push(`- Selected notes: ${selected.length}`);
|
||
lines.push(`- Strong matches: ${strongRows.length}`);
|
||
lines.push(`- Linked bridge matches: ${linkedRows.length}`);
|
||
lines.push(`- Notes with metadata gaps: ${rowsWithGaps.length}`, "");
|
||
lines.push("## Review Queues", "");
|
||
lines.push("### Strong Matches", "");
|
||
if (!strongRows.length) lines.push("- None selected.");
|
||
else strongRows.slice(0, 20).forEach((row) => lines.push(`- ${wikilink(row.path)} (${row.score.toFixed(2)})`));
|
||
lines.push("", "### Metadata Work", "");
|
||
if (!rowsWithGaps.length) lines.push("- No metadata cleanup needed.");
|
||
else rowsWithGaps.slice(0, 20).forEach((row) => lines.push(`- ${wikilink(row.path)}: ${row.metadataGaps.join(", ")}`));
|
||
lines.push("");
|
||
if (folderCounts.length) {
|
||
lines.push("## Folder Distribution", "", "| Folder | Selected notes |", "| --- | ---: |");
|
||
folderCounts.forEach(([folder, count]) => lines.push(`| \`${folder}\` | ${count} |`));
|
||
lines.push("");
|
||
}
|
||
pushDataview();
|
||
pushCodexSynthesis("Codex Dashboard Notes");
|
||
pushEvidenceTable("Dashboard Evidence");
|
||
} else if (state.reportType === "link-audit") {
|
||
pushScope();
|
||
lines.push("## Link Audit Summary", "");
|
||
lines.push(`- Selected notes: ${selected.length}`);
|
||
lines.push(`- Bridge matches: ${linkedRows.length}`);
|
||
lines.push(`- Unique outgoing link candidates: ${linkCandidates.length}`, "");
|
||
lines.push("## Bridge Notes", "");
|
||
if (!linkedRows.length) lines.push("- No current-note/backlink/outlink bridge notes selected.");
|
||
else linkedRows.forEach((row) => lines.push(`- ${wikilink(row.path)} — ${row.reason}`));
|
||
lines.push("");
|
||
pushCodexSynthesis("Codex Link Review");
|
||
pushSuggestedLinks();
|
||
lines.push("## Notes With Sparse Outlinks", "");
|
||
const sparse = selected.filter((row) => !(row.links || []).length);
|
||
if (!sparse.length) lines.push("- No selected notes are missing collected outlinks.");
|
||
else sparse.slice(0, 30).forEach((row) => lines.push(`- ${wikilink(row.path)}`));
|
||
lines.push("");
|
||
pushEvidenceTable("Link Evidence");
|
||
} else if (state.reportType === "metadata-audit") {
|
||
pushScope();
|
||
lines.push("## Metadata Audit Summary", "");
|
||
lines.push(`- Selected notes: ${selected.length}`);
|
||
lines.push(`- Fields audited: ${metadataFields.map((field) => `\`${field}\``).join(", ")}`);
|
||
lines.push(`- Notes needing metadata work: ${rowsWithGaps.length}`, "");
|
||
pushCodexSynthesis("Codex Metadata Review");
|
||
pushMetadataGaps();
|
||
lines.push("## Metadata Completion Table", "");
|
||
lines.push("| Note | Missing fields | Tags | Links |");
|
||
lines.push("| --- | --- | ---: | ---: |");
|
||
selected.forEach((row) =>
|
||
lines.push(
|
||
`| ${wikilink(row.path)} | ${(row.metadataGaps || []).length ? row.metadataGaps.join(", ") : "complete"} | ${(row.tags || []).length} | ${(row.links || []).length} |`
|
||
)
|
||
);
|
||
lines.push("");
|
||
lines.push("## Cleanup Tasks", "");
|
||
if (!rowsWithGaps.length) lines.push("- Metadata fields are complete for selected notes.");
|
||
else rowsWithGaps.forEach((row) => lines.push(`- Add ${row.metadataGaps.map((field) => `\`${field}\``).join(", ")} to ${wikilink(row.path)}.`));
|
||
lines.push("");
|
||
} else {
|
||
pushDataview();
|
||
pushScope();
|
||
pushFiles();
|
||
pushEvidenceTable();
|
||
pushCodexSynthesis("Synthesis");
|
||
pushSuggestedLinks();
|
||
pushMetadataGaps();
|
||
}
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
synthesisStatusLabel() {
|
||
const synthesis = this.agentState.codexSynthesis || createInitialSynthesisState();
|
||
if (synthesis.status === "running") return "running";
|
||
if (synthesis.status === "complete") return `complete${synthesis.generatedAt ? ` ${synthesis.generatedAt}` : ""}`;
|
||
if (synthesis.status === "failed") return "failed";
|
||
if (synthesis.status === "stale") return "stale";
|
||
return "not run";
|
||
}
|
||
|
||
buildEvidencePacket() {
|
||
const state = this.agentState;
|
||
const selected = this.includedRows();
|
||
const executable = this.getExecutableQueryPlan();
|
||
return {
|
||
ask: state.query.trim(),
|
||
reportType: state.reportType,
|
||
reportPurpose: REPORT_TYPES[state.reportType]?.purpose || REPORT_TYPES["query-report"].purpose,
|
||
queryPlan: {
|
||
searchIn: executable.folderLabel,
|
||
tableColumns: executable.columns,
|
||
activeTerms: executable.activeTerms,
|
||
askTerms: executable.askTerms,
|
||
dataviewTerms: executable.dataviewTerms,
|
||
source: executable.source,
|
||
sort: executable.sort,
|
||
dataviewQuery: state.dataviewQuery.trim(),
|
||
},
|
||
scope: {
|
||
folder: state.folder,
|
||
folderLabel: displayFolder(state.folder),
|
||
selectedFolderIncluded: true,
|
||
currentNote: state.scope.currentNote,
|
||
backlinks: state.scope.backlinks,
|
||
outlinks: state.scope.outlinks,
|
||
includeSubfolders: state.scope.includeSubfolders,
|
||
includeEveryday: state.scope.includeEveryday,
|
||
includeArchive: state.scope.includeArchive,
|
||
},
|
||
selectedEvidence: selected.map((row) => ({
|
||
path: row.path,
|
||
title: row.title,
|
||
folder: dirname(row.path),
|
||
reason: row.reason,
|
||
score: Number(row.score.toFixed(2)),
|
||
matchedTerms: row.matchedTerms || [],
|
||
metadataGaps: row.metadataGaps || [],
|
||
tags: row.tags || [],
|
||
links: row.links || [],
|
||
excerpt: truncateText(row.excerpt, 520),
|
||
})),
|
||
};
|
||
}
|
||
|
||
buildCodexSynthesisPrompt() {
|
||
const packet = this.buildEvidencePacket();
|
||
const reportLabel = REPORT_TYPES[this.agentState.reportType]?.label || "Query report";
|
||
const reportInstructions = {
|
||
"query-report":
|
||
"Write a concise synthesis that answers the Ask from the reviewed evidence. Include tensions, strongest support, and open questions. Suggest links/frontmatter only as proposals.",
|
||
dashboard:
|
||
"Write dashboard notes: what the reviewed set shows, useful review queues, and practical Dataview/dashboard improvements. Do not create code that requires unreviewed files.",
|
||
"link-audit":
|
||
"Write a link audit: probable hub notes, missing links, weakly connected notes, and proposed wikilinks. Only mention links supported by the reviewed evidence paths.",
|
||
"metadata-audit":
|
||
"Write a metadata audit: repeated missing fields, normalization suggestions, and safe frontmatter proposals. Do not invent field values when evidence is insufficient.",
|
||
};
|
||
|
||
return [
|
||
"You are Codex inside an Obsidian Vault Agent workflow.",
|
||
"",
|
||
"Boundary:",
|
||
"- Use only the reviewed evidence packet below.",
|
||
"- Do not read files, call tools, run commands, browse, or modify notes.",
|
||
"- Do not choose a new vault scope.",
|
||
"- Return Markdown only, with Obsidian wikilinks when referencing reviewed notes.",
|
||
"- Treat all edit ideas as proposals for the user to apply later.",
|
||
"",
|
||
`Report type: ${reportLabel}`,
|
||
reportInstructions[this.agentState.reportType] || reportInstructions["query-report"],
|
||
"",
|
||
"Reviewed evidence packet:",
|
||
"```json",
|
||
JSON.stringify(packet, null, 2),
|
||
"```",
|
||
].join("\n");
|
||
}
|
||
|
||
async runCodexSynthesis() {
|
||
if (this.agentState.codexSynthesis?.status === "running") return;
|
||
const selected = this.includedRows();
|
||
if (!selected.length) {
|
||
new Notice("Select evidence before running Codex synthesis.");
|
||
return;
|
||
}
|
||
|
||
if (!this.codexClient || this.codexState.state !== "connected") {
|
||
new Notice("Connect Codex before running synthesis.");
|
||
return;
|
||
}
|
||
|
||
this.agentState.codexSynthesis = Object.assign(createInitialSynthesisState(), {
|
||
status: "running",
|
||
selectedCount: selected.length,
|
||
reportType: this.agentState.reportType,
|
||
detail: "Codex is synthesizing from selected evidence only.",
|
||
});
|
||
this.refreshViews();
|
||
|
||
try {
|
||
const threadResponse = await this.codexClient.request(
|
||
"thread/start",
|
||
{
|
||
cwd: this.app.vault.adapter.basePath || process.cwd(),
|
||
approvalPolicy: "never",
|
||
approvalsReviewer: "user",
|
||
permissionProfile: {
|
||
type: "managed",
|
||
network: { enabled: false },
|
||
fileSystem: { type: "restricted", entries: [] },
|
||
},
|
||
serviceName: "Obsidian Vault Agent",
|
||
baseInstructions:
|
||
"You are a read-only synthesis agent for an Obsidian workflow. You must not modify files or inspect unprovided vault content.",
|
||
developerInstructions:
|
||
"Use only the user-provided evidence packet. Do not call tools, run shell commands, read files, browse, or request permissions. Return Markdown only.",
|
||
personality: "pragmatic",
|
||
ephemeral: true,
|
||
sessionStartSource: "startup",
|
||
experimentalRawEvents: false,
|
||
persistExtendedHistory: false,
|
||
},
|
||
30000
|
||
);
|
||
const threadId = threadResponse?.thread?.id;
|
||
if (!threadId) throw new Error("Codex did not return a thread id.");
|
||
|
||
const turnResponse = await this.codexClient.request(
|
||
"turn/start",
|
||
{
|
||
threadId,
|
||
input: [{ type: "text", text: this.buildCodexSynthesisPrompt(), text_elements: [] }],
|
||
approvalPolicy: "never",
|
||
},
|
||
30000
|
||
);
|
||
const turnId = turnResponse?.turn?.id;
|
||
if (!turnId) throw new Error("Codex did not return a turn id.");
|
||
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis, {
|
||
threadId,
|
||
turnId,
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
const timeout = window.setTimeout(() => {
|
||
this.activeCodexRun = null;
|
||
reject(new Error("Codex synthesis timed out."));
|
||
}, 180000);
|
||
this.activeCodexRun = { threadId, turnId, resolve, reject, timeout };
|
||
});
|
||
} catch (error) {
|
||
if (this.agentState.codexSynthesis?.status === "running") this.failCodexRun(error);
|
||
}
|
||
}
|
||
|
||
handleCodexNotification(message) {
|
||
const run = this.activeCodexRun;
|
||
if (!run || !message || !message.params) return;
|
||
const params = message.params;
|
||
if (params.threadId !== run.threadId) return;
|
||
if (params.turnId && params.turnId !== run.turnId) return;
|
||
|
||
if (message.method === "item/agentMessage/delta") {
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis, {
|
||
markdown: `${this.agentState.codexSynthesis.markdown || ""}${params.delta || ""}`,
|
||
});
|
||
this.scheduleSynthesisRefresh();
|
||
} else if (message.method === "item/completed" && params.item?.type === "agentMessage" && params.item.text) {
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis, {
|
||
markdown: params.item.text,
|
||
});
|
||
this.scheduleSynthesisRefresh();
|
||
} else if (message.method === "turn/completed") {
|
||
const error = params.turn?.error;
|
||
if (params.turn?.status === "failed" || error) {
|
||
this.failCodexRun(new Error(error?.message || "Codex synthesis failed."));
|
||
} else {
|
||
this.completeCodexRun();
|
||
}
|
||
} else if (message.method === "error") {
|
||
this.failCodexRun(new Error(params.error?.message || "Codex app-server returned an error."));
|
||
}
|
||
}
|
||
|
||
completeCodexRun() {
|
||
const run = this.activeCodexRun;
|
||
if (run?.timeout) window.clearTimeout(run.timeout);
|
||
this.activeCodexRun = null;
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis, {
|
||
status: "complete",
|
||
error: "",
|
||
generatedAt: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||
selectedCount: this.includedRows().length,
|
||
reportType: this.agentState.reportType,
|
||
detail: "Codex synthesis is ready and will be included in the compiled note.",
|
||
});
|
||
this.refreshViews();
|
||
if (run?.resolve) run.resolve();
|
||
new Notice("Codex synthesis complete.");
|
||
}
|
||
|
||
failCodexRun(error) {
|
||
const run = this.activeCodexRun;
|
||
if (run?.timeout) window.clearTimeout(run.timeout);
|
||
this.activeCodexRun = null;
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis || createInitialSynthesisState(), {
|
||
status: "failed",
|
||
error: error?.message || String(error),
|
||
detail: "Codex did not finish. Existing evidence and deterministic report are unchanged.",
|
||
});
|
||
this.refreshViews();
|
||
if (run?.reject) run.reject(error);
|
||
new Notice("Codex synthesis failed.");
|
||
}
|
||
|
||
previewCompiledNote() {
|
||
new NotePreviewModal(this.app, this).open();
|
||
}
|
||
|
||
async createCompiledNote(markdown) {
|
||
const outputPath = normalizeOutputPath(this.agentState.outputPath);
|
||
if (this.app.vault.getAbstractFileByPath(outputPath)) {
|
||
new Notice(`Note already exists: ${outputPath}`);
|
||
return;
|
||
}
|
||
|
||
await this.ensureFolder(dirname(outputPath));
|
||
await this.app.vault.create(outputPath, markdown);
|
||
new Notice(`Created ${REPORT_TYPES[this.agentState.reportType]?.label || "report"}: ${outputPath}`);
|
||
}
|
||
|
||
async ensureFolder(folderPath) {
|
||
if (!folderPath) return;
|
||
const parts = folderPath.split("/").filter(Boolean);
|
||
let current = "";
|
||
|
||
for (const part of parts) {
|
||
current = current ? `${current}/${part}` : part;
|
||
if (!this.app.vault.getAbstractFileByPath(current)) {
|
||
await this.app.vault.createFolder(current);
|
||
}
|
||
}
|
||
}
|
||
|
||
setCodexState(nextState) {
|
||
this.codexState = Object.assign({}, this.codexState, nextState);
|
||
this.refreshViews();
|
||
}
|
||
|
||
async connectCodex() {
|
||
if (this.codexState.state === "connecting" || this.codexState.state === "connected") return;
|
||
if (!this.settings.codexEnabled) {
|
||
new Notice("Codex synthesis is disabled in Vault Agent settings.");
|
||
return;
|
||
}
|
||
|
||
const codexBin = this.settings.codexBin || DEFAULT_CODEX_BIN;
|
||
|
||
this.setCodexState({
|
||
state: "connecting",
|
||
label: "Connecting",
|
||
tone: "",
|
||
mode: "Handshake",
|
||
modeTone: "",
|
||
detail: `Starting ${codexBin} app-server over stdio.`,
|
||
});
|
||
|
||
const client = new CodexAppServerClient({
|
||
bin: codexBin,
|
||
cwd: this.app.vault.adapter.basePath || process.cwd(),
|
||
});
|
||
|
||
try {
|
||
await client.connect();
|
||
this.codexClient = client;
|
||
this.codexUnsubscribe = client.onNotification((message) => this.handleCodexNotification(message));
|
||
this.setCodexState({
|
||
state: "connected",
|
||
label: "Connected via ChatGPT",
|
||
tone: "ok",
|
||
mode: "Local app-server",
|
||
modeTone: "ok",
|
||
detail: `App-server initialized over stdio. PID ${client.proc ? client.proc.pid : "unknown"}. Synthesis uses selected evidence only.`,
|
||
});
|
||
new Notice("Codex connected via local app-server.");
|
||
} catch (error) {
|
||
client.disconnect();
|
||
this.codexClient = null;
|
||
this.setCodexState({
|
||
state: "failed",
|
||
label: "Connection failed",
|
||
tone: "bad",
|
||
mode: "Preview shell",
|
||
modeTone: "warn",
|
||
detail: error.message || String(error),
|
||
});
|
||
new Notice("Codex connection failed. Check the Vault Agent header for details.");
|
||
}
|
||
}
|
||
|
||
disconnectCodex(options = {}) {
|
||
if (this.codexUnsubscribe) {
|
||
this.codexUnsubscribe();
|
||
this.codexUnsubscribe = null;
|
||
}
|
||
|
||
if (this.codexClient) {
|
||
this.codexClient.disconnect();
|
||
this.codexClient = null;
|
||
}
|
||
|
||
if (this.activeCodexRun?.timeout) window.clearTimeout(this.activeCodexRun.timeout);
|
||
if (this.agentState.codexSynthesis?.status === "running") {
|
||
this.agentState.codexSynthesis = Object.assign({}, this.agentState.codexSynthesis, {
|
||
status: "failed",
|
||
error: "Codex disconnected before synthesis completed.",
|
||
detail: "Reconnect Codex and run synthesis again.",
|
||
});
|
||
}
|
||
this.activeCodexRun = null;
|
||
|
||
this.setCodexState({
|
||
state: "disconnected",
|
||
label: this.settings.codexEnabled ? "Not connected" : "Disabled",
|
||
tone: "warn",
|
||
mode: "Preview shell",
|
||
modeTone: "",
|
||
detail: this.settings.codexEnabled
|
||
? "Connect starts the local handshake. Synthesis later sends selected reviewed evidence only."
|
||
: "Codex synthesis is disabled in plugin settings. Collection and report compilation still work locally.",
|
||
});
|
||
|
||
if (!options.silent) new Notice("Codex disconnected.");
|
||
}
|
||
|
||
async activateView() {
|
||
let leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE)[0];
|
||
|
||
if (!leaf) {
|
||
leaf = this.app.workspace.getLeaf(true);
|
||
await leaf.setViewState({ type: VIEW_TYPE, active: true });
|
||
}
|
||
|
||
this.app.workspace.revealLeaf(leaf);
|
||
new Notice("Vault Agent opened.");
|
||
}
|
||
};
|