hendrikhuwy-lgtm_codex-vaul.../main.js
2026-04-30 12:54:24 +08:00

2291 lines
86 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.");
}
};