Add automatic SideNote2 agent routing support

This commit is contained in:
vicky469 2026-04-07 10:13:01 -04:00
parent d0c9109754
commit 2643940fb3
21 changed files with 1736 additions and 185 deletions

45
AGENTS.md Normal file
View file

@ -0,0 +1,45 @@
# SideNote2 Agent Routing
When a user is working with real SideNote2 comments in an Obsidian vault, do not start from plugin internals.
Use the SideNote2 note workflow first.
## Use The `sidenote2` Skill
Switch to `skills/sidenote2/SKILL.md` when the user:
- pastes an `obsidian://side-note2-comment?...` URI
- says `reply to this`, `reply to this thread`, `answer this side note`, or `add to thread`
- says `update this side note`, `rewrite this side comment`, or `edit this stored side note`
- says `resolve this side note`, `mark this thread resolved`, or `archive this side note`
- provides a `commentId` plus a vault note path
- asks about the trailing `<!-- SideNote2 comments -->` block in a real markdown note
## Source Of Truth
- The markdown note is canonical.
- The trailing `<!-- SideNote2 comments -->` block is the canonical stored comment data.
- `SideNote2 index.md` is derived output. Use it for discovery only.
## Write Path
For SideNote2 thread writes, prefer the helper scripts over hand-editing JSON:
- `node scripts/append-note-comment-entry.mjs`
- `node scripts/resolve-note-comment.mjs`
- `node scripts/update-note-comment.mjs`
If the user already supplied an `obsidian://side-note2-comment?...` URI, prefer the URI-based CLI path:
- `--uri "obsidian://side-note2-comment?..."`
## Intent Mapping
- `reply`, `continue`, `answer this`, `add another note under this`
Treat as append-to-thread.
- `update`, `rewrite`, `replace this comment`
Treat as replace-existing-entry.
- `resolve`, `mark resolved`, `archive this side note`
Treat as resolve-thread.
Do not overwrite an existing SideNote2 thread when the user clearly asked to reply.

View file

@ -59,15 +59,25 @@ It is built for a minimal workflow: humans work in the sidebar, while agents can
<p align="center">
<img src="./image.png" alt="Install SideNote2 with BRAT" width="420">
</p>
3. Optional: use the bundled SideNote2 agent instructions.
3. Agent support for SideNote2 workflows is automatic.
Example install for Codex CLI:
```bash
python ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py --repo \
vicky469/SideNote2 --path skills/side-note2-note-comments skills/canvas-design
```
If an agent is working directly inside the Obsidian vault, SideNote2 manages a vault-root `AGENTS.md`.
When the plugin is enabled, it creates the file when missing, or inserts and refreshes a SideNote2-managed block inside an existing `AGENTS.md` without overwriting unrelated user instructions.
`side-note2-note-comments` helps agents read and update SideNote2-backed notes. `canvas-design` is a bundled Obsidian canvas-layout skill for cleaner spacing, grouping, and board readability. If you use Claude Code or another assistant, adapt the same instructions there.
You can run it manually from the command palette if needed:
- `SideNote2: Sync AGENTS.md in vault root`
If you want to remove the SideNote2-managed agent support later, use:
- `SideNote2: Remove SideNote2 agent support from vault`
That vault `AGENTS.md` is the main routing layer for SideNote2 note workflows such as:
- resolving `obsidian://side-note2-comment?...` links
- replying to an existing side note thread
- resolving a side note thread
- updating a stored SideNote2 comment in a real markdown note
## Workflow
@ -119,6 +129,8 @@ If multiple side comments in the same note use the same selected text, include a
## Command
- `SideNote2: Add comment to selection`
- `SideNote2: Sync AGENTS.md in vault root`
- `SideNote2: Remove SideNote2 agent support from vault`
## Storage

View file

@ -22,7 +22,9 @@ function printMainUsage(stream = process.stderr) {
" comment:migrate-legacy Maintenance: rewrite one note from legacy flat comments to threaded storage",
" comment:append Append one entry to an existing SideNote2 comment thread in a note",
" comment:update Update one stored SideNote2 comment body in a note",
" comment:resolve Mark one SideNote2 comment thread as resolved in a note",
" install-skill Copy bundled SideNote2 Codex skill(s) into the Codex skills directory",
" uninstall-agent-support Remove SideNote2 AGENTS routing from a vault and uninstall bundled skills",
"",
"Run `sidenote2 <command> --help` for command-specific usage.",
].join("\n") + "\n",
@ -33,10 +35,11 @@ function printCommentUpdateUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" sidenote2 comment:update --file <note.md> --id <comment-id> (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
" sidenote2 comment:update (--file <note.md> --id <comment-id> | --uri <obsidian://side-note2-comment?...>) (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
"",
"Examples:",
" sidenote2 comment:update --file ./note.md --id comment-1 --comment-file ./comment.md",
" sidenote2 comment:update --uri \"obsidian://side-note2-comment?...\" --comment-file ./comment.md",
" sidenote2 comment:update --file ./note.md --id comment-1 --comment-file ./comment.md --settle-ms 2000",
" printf 'Updated body\\n' | sidenote2 comment:update --file ./note.md --id comment-1 --stdin",
].join("\n") + "\n",
@ -47,16 +50,31 @@ function printCommentAppendUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" sidenote2 comment:append --file <note.md> --id <comment-id> (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
" sidenote2 comment:append (--file <note.md> --id <comment-id> | --uri <obsidian://side-note2-comment?...>) (--comment <text> | --comment-file <path> | --stdin) [--settle-ms <milliseconds>]",
"",
"Examples:",
" sidenote2 comment:append --file ./note.md --id comment-1 --comment-file ./reply.md",
" sidenote2 comment:append --uri \"obsidian://side-note2-comment?...\" --comment-file ./reply.md",
" sidenote2 comment:append --file ./note.md --id comment-1 --comment-file ./reply.md --settle-ms 2000",
" printf 'Reply body\\n' | sidenote2 comment:append --file ./note.md --id comment-1 --stdin",
].join("\n") + "\n",
);
}
function printCommentResolveUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" sidenote2 comment:resolve (--file <note.md> --id <comment-id> | --uri <obsidian://side-note2-comment?...>) [--settle-ms <milliseconds>]",
"",
"Examples:",
" sidenote2 comment:resolve --file ./note.md --id comment-1",
" sidenote2 comment:resolve --uri \"obsidian://side-note2-comment?...\"",
" sidenote2 comment:resolve --file ./note.md --id comment-1 --settle-ms 2000",
].join("\n") + "\n",
);
}
function printCommentMigrateLegacyUsage(stream = process.stderr) {
stream.write(
[
@ -96,10 +114,28 @@ function printSkillUsage(command, stream = process.stderr) {
);
}
function printUninstallAgentSupportUsage(stream = process.stderr) {
stream.write(
[
"Usage:",
" sidenote2 uninstall-agent-support (--vault-root <vault-dir> | --vault <vault-name>) [--skills-root <skills-root>]",
"",
"Defaults:",
" --skills-root defaults to $CODEX_HOME/skills or ~/.codex/skills",
"",
"Examples:",
" sidenote2 uninstall-agent-support --vault-root /path/to/vault",
" sidenote2 uninstall-agent-support --vault public",
" sidenote2 uninstall-agent-support --vault-root /path/to/vault --skills-root /tmp/skills",
].join("\n") + "\n",
);
}
function parseCommentUpdateArgs(argv) {
const options = {
file: "",
id: "",
uri: "",
comment: null,
commentFile: "",
stdin: false,
@ -117,6 +153,10 @@ function parseCommentUpdateArgs(argv) {
options.id = argv[index + 1] ?? "";
index += 1;
break;
case "--uri":
options.uri = argv[index + 1] ?? "";
index += 1;
break;
case "--comment":
options.comment = argv[index + 1] ?? "";
index += 1;
@ -141,8 +181,11 @@ function parseCommentUpdateArgs(argv) {
}
const contentSources = [options.comment !== null, Boolean(options.commentFile), options.stdin].filter(Boolean).length;
if (!options.file || !options.id || contentSources !== 1) {
throw new Error("Expected --file, --id, and exactly one comment source.");
const hasFileOrIdTarget = Boolean(options.file || options.id);
const hasFileAndIdTarget = Boolean(options.file && options.id);
const hasUriTarget = Boolean(options.uri);
if (contentSources !== 1 || (hasFileOrIdTarget ? 1 : 0) + (hasUriTarget ? 1 : 0) !== 1 || (hasFileOrIdTarget && !hasFileAndIdTarget)) {
throw new Error("Expected exactly one target form: either --file with --id, or --uri, plus exactly one comment source.");
}
return options;
@ -152,6 +195,51 @@ function parseCommentAppendArgs(argv) {
return parseCommentUpdateArgs(argv);
}
function parseCommentResolveArgs(argv) {
const options = {
file: "",
id: "",
uri: "",
settleMs: 0,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--file":
options.file = argv[index + 1] ?? "";
index += 1;
break;
case "--id":
options.id = argv[index + 1] ?? "";
index += 1;
break;
case "--uri":
options.uri = argv[index + 1] ?? "";
index += 1;
break;
case "--settle-ms":
options.settleMs = parseNonNegativeIntegerOption(argv[index + 1] ?? "", "--settle-ms");
index += 1;
break;
case "--help":
case "-h":
return null;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
const hasFileOrIdTarget = Boolean(options.file || options.id);
const hasFileAndIdTarget = Boolean(options.file && options.id);
const hasUriTarget = Boolean(options.uri);
if ((hasFileOrIdTarget ? 1 : 0) + (hasUriTarget ? 1 : 0) !== 1 || (hasFileOrIdTarget && !hasFileAndIdTarget)) {
throw new Error("Expected exactly one target form: either --file with --id, or --uri.");
}
return options;
}
function parseCommentMigrateLegacyArgs(argv) {
const options = {
file: "",
@ -227,6 +315,44 @@ function parseSkillArgs(argv) {
return options;
}
function parseUninstallAgentSupportArgs(argv) {
const options = {
vaultRoot: "",
vaultName: "",
skillsRoot: getDefaultSkillsRoot(),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--vault-root":
options.vaultRoot = argv[index + 1] ?? "";
index += 1;
break;
case "--vault":
options.vaultName = argv[index + 1] ?? "";
index += 1;
break;
case "--skills-root":
options.skillsRoot = path.resolve(process.cwd(), argv[index + 1] ?? "");
index += 1;
break;
case "--help":
case "-h":
return null;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
const targetCount = [Boolean(options.vaultRoot), Boolean(options.vaultName)].filter(Boolean).length;
if (targetCount !== 1) {
throw new Error("Expected exactly one of --vault-root or --vault.");
}
return options;
}
function getDefaultSkillsRoot() {
const codexHome = process.env.CODEX_HOME?.trim();
return codexHome
@ -367,6 +493,127 @@ async function loadCommentBody(options) {
return readStdin();
}
const COMMENT_LOCATION_PROTOCOL = "side-note2-comment";
const VAULT_AGENTS_MANAGED_BLOCK_START_PREFIX = "<!-- SideNote2 managed AGENTS start";
const VAULT_AGENTS_MANAGED_BLOCK_END = "<!-- SideNote2 managed AGENTS end -->";
function parseCommentProtocolUri(uri) {
try {
const parsed = new URL(uri);
if (parsed.protocol !== "obsidian:" || parsed.hostname !== COMMENT_LOCATION_PROTOCOL) {
return null;
}
const vaultName = parsed.searchParams.get("vault");
const filePath = parsed.searchParams.get("file");
const commentId = parsed.searchParams.get("commentId");
if (!(vaultName && filePath && commentId)) {
return null;
}
return {
vaultName,
filePath,
commentId,
};
} catch {
return null;
}
}
function getObsidianConfigPath() {
const explicitConfigPath = process.env.OBSIDIAN_CONFIG_PATH?.trim();
return explicitConfigPath
? path.resolve(process.cwd(), explicitConfigPath)
: path.join(homedir(), ".config", "obsidian", "obsidian.json");
}
async function resolveVaultRootByName(vaultName) {
const configPath = getObsidianConfigPath();
let config;
try {
config = JSON.parse(await readFile(configPath, "utf8"));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Could not read Obsidian vault config at ${configPath}: ${reason}`);
}
const configuredVaults = config?.vaults;
if (!configuredVaults || typeof configuredVaults !== "object") {
throw new Error(`Obsidian vault config at ${configPath} does not contain a valid vault list.`);
}
const matchingVaultRoots = [];
for (const value of Object.values(configuredVaults)) {
if (!value || typeof value !== "object") {
continue;
}
const vaultPath = typeof value.path === "string" ? value.path : "";
if (!vaultPath) {
continue;
}
const resolvedVaultPath = path.resolve(vaultPath);
if (path.basename(resolvedVaultPath) === vaultName) {
matchingVaultRoots.push(resolvedVaultPath);
}
}
if (matchingVaultRoots.length === 0) {
throw new Error(`Could not resolve Obsidian vault "${vaultName}" from ${configPath}.`);
}
if (matchingVaultRoots.length > 1) {
throw new Error(`Found multiple Obsidian vaults named "${vaultName}" in ${configPath}.`);
}
return matchingVaultRoots[0];
}
async function resolveVaultRootForAgentSupport(options) {
if (options.vaultRoot) {
return path.resolve(process.cwd(), options.vaultRoot);
}
return resolveVaultRootByName(options.vaultName);
}
function resolveVaultRelativeNotePath(vaultRoot, filePath) {
const resolvedNotePath = path.resolve(vaultRoot, filePath);
const relativePath = path.relative(vaultRoot, resolvedNotePath);
if (
relativePath.startsWith("..")
|| path.isAbsolute(relativePath)
|| relativePath === ""
|| relativePath === "."
) {
throw new Error(`Comment URI file path escapes the resolved vault root: ${filePath}`);
}
return resolvedNotePath;
}
async function resolveCommentWriteTarget(options) {
if (options.uri) {
const uriTarget = parseCommentProtocolUri(options.uri);
if (!uriTarget) {
throw new Error("Expected --uri to be an obsidian://side-note2-comment link with vault, file, and commentId.");
}
const vaultRoot = await resolveVaultRootByName(uriTarget.vaultName);
return {
notePath: resolveVaultRelativeNotePath(vaultRoot, uriTarget.filePath),
commentId: uriTarget.commentId,
};
}
return {
notePath: path.resolve(process.cwd(), options.file),
commentId: options.id,
};
}
async function getBundledSkills() {
const repoRoot = getRepoRoot(import.meta.url);
const skillsRoot = path.join(repoRoot, "skills");
@ -397,6 +644,79 @@ async function getBundledSkills() {
return { repoRoot, skillDirectories };
}
function normalizeTextLineEndings(content) {
return content.replace(/\r\n/g, "\n");
}
function findVaultAgentsManagedBlockRange(content) {
const normalizedContent = normalizeTextLineEndings(content);
const start = normalizedContent.indexOf(VAULT_AGENTS_MANAGED_BLOCK_START_PREFIX);
if (start === -1) {
return null;
}
const endMarkerStart = normalizedContent.indexOf(VAULT_AGENTS_MANAGED_BLOCK_END, start);
if (endMarkerStart === -1) {
return null;
}
let end = endMarkerStart + VAULT_AGENTS_MANAGED_BLOCK_END.length;
if (normalizedContent.charAt(end) === "\n") {
end += 1;
}
return { start, end };
}
function isLegacyManagedVaultAgentsFileContent(content) {
const normalizedContent = normalizeTextLineEndings(content).trim();
return normalizedContent.startsWith("# SideNote2 Vault Agent Routing")
&& normalizedContent.includes("When a user is working with real SideNote2 comments in this vault:")
&& normalizedContent.includes("obsidian://side-note2-comment?...");
}
function removeManagedBlockFromDocument(existingContent, blockRange) {
const normalizedContent = normalizeTextLineEndings(existingContent);
const before = normalizedContent.slice(0, blockRange.start).replace(/\s+$/u, "");
const after = normalizedContent.slice(blockRange.end).replace(/^\s+/u, "");
if (!before && !after) {
return "";
}
if (!before) {
return `${after}\n`;
}
if (!after) {
return `${before}\n`;
}
return `${before}\n\n${after}\n`;
}
function stripSideNote2ManagedAgentsContent(existingContent) {
const managedBlockRange = findVaultAgentsManagedBlockRange(existingContent);
if (managedBlockRange) {
return {
kind: "removed",
nextContent: removeManagedBlockFromDocument(existingContent, managedBlockRange),
};
}
if (isLegacyManagedVaultAgentsFileContent(existingContent)) {
return {
kind: "removed",
nextContent: "",
};
}
return {
kind: "noop",
nextContent: normalizeTextLineEndings(existingContent),
};
}
async function loadStorageModule(repoRoot) {
const entryPoint = path.resolve(repoRoot, "src/core/storage/noteCommentStorage.ts");
const result = await esbuild.build({
@ -704,11 +1024,20 @@ async function runCommentUpdate(argv, streamOut, streamErr) {
}
const repoRoot = getRepoRoot(import.meta.url);
const notePath = path.resolve(process.cwd(), options.file);
let writeTarget;
try {
writeTarget = await resolveCommentWriteTarget(options);
} catch (error) {
streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const notePath = writeTarget.notePath;
const commentId = writeTarget.commentId;
const nextCommentBody = await loadCommentBody(options);
const noteContent = await readFile(notePath, "utf8");
const storageModule = await loadStorageModule(repoRoot);
const updated = storageModule.replaceNoteCommentBodyById(noteContent, notePath, options.id, nextCommentBody);
const updated = storageModule.replaceNoteCommentBodyById(noteContent, notePath, commentId, nextCommentBody);
if (typeof updated !== "string") {
const plan = buildLegacyMigrationPlan(notePath, noteContent, storageModule);
@ -721,7 +1050,7 @@ async function runCommentUpdate(argv, streamOut, streamErr) {
return 1;
}
streamErr.write(`Comment id not found: ${options.id}\n`);
streamErr.write(`Comment id not found: ${commentId}\n`);
return 1;
}
@ -736,7 +1065,7 @@ async function runCommentUpdate(argv, streamOut, streamErr) {
return 1;
}
streamOut.write(`Updated comment ${options.id} in ${notePath}\n`);
streamOut.write(`Updated comment ${commentId} in ${notePath}\n`);
return 0;
}
@ -756,11 +1085,20 @@ async function runCommentAppend(argv, streamOut, streamErr) {
}
const repoRoot = getRepoRoot(import.meta.url);
const notePath = path.resolve(process.cwd(), options.file);
let writeTarget;
try {
writeTarget = await resolveCommentWriteTarget(options);
} catch (error) {
streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const notePath = writeTarget.notePath;
const commentId = writeTarget.commentId;
const nextCommentBody = await loadCommentBody(options);
const noteContent = await readFile(notePath, "utf8");
const storageModule = await loadStorageModule(repoRoot);
const updated = storageModule.appendNoteCommentEntryById(noteContent, notePath, options.id, {
const updated = storageModule.appendNoteCommentEntryById(noteContent, notePath, commentId, {
id: randomUUID(),
body: nextCommentBody,
timestamp: Date.now(),
@ -789,7 +1127,7 @@ async function runCommentAppend(argv, streamOut, streamErr) {
return 1;
}
streamErr.write(`Comment id not found: ${options.id}\n`);
streamErr.write(`Comment id not found: ${commentId}\n`);
return 1;
}
@ -804,7 +1142,79 @@ async function runCommentAppend(argv, streamOut, streamErr) {
return 1;
}
streamOut.write(`Appended a new entry to comment ${options.id} in ${notePath}\n`);
streamOut.write(`Appended a new entry to comment ${commentId} in ${notePath}\n`);
return 0;
}
async function runCommentResolve(argv, streamOut, streamErr) {
let options;
try {
options = parseCommentResolveArgs(argv);
} catch (error) {
streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printCommentResolveUsage(streamErr);
return 1;
}
if (options === null) {
printCommentResolveUsage(streamOut);
return 0;
}
const repoRoot = getRepoRoot(import.meta.url);
let writeTarget;
try {
writeTarget = await resolveCommentWriteTarget(options);
} catch (error) {
streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
const notePath = writeTarget.notePath;
const commentId = writeTarget.commentId;
const noteContent = await readFile(notePath, "utf8");
const storageModule = await loadStorageModule(repoRoot);
const updated = storageModule.resolveNoteCommentById(noteContent, notePath, commentId);
if (typeof updated !== "string") {
const plan = buildLegacyMigrationPlan(notePath, noteContent, storageModule);
if (plan.kind === "legacy") {
streamErr.write(
`Note still uses legacy flat SideNote2 comments: ${notePath}\n`
+ "Open the vault once in SideNote2 2.0.1+ so startup can auto-migrate it, "
+ "or use `sidenote2 comment:migrate-legacy` only for out-of-band maintenance.\n",
);
return 1;
}
if (plan.kind === "unsupported") {
streamErr.write(
`Found a SideNote2 comments block in ${notePath}, but it is not a supported threaded payload.\n`,
);
return 1;
}
if (plan.kind === "no-managed-block") {
streamErr.write(`No SideNote2 comments block found in ${notePath}\n`);
return 1;
}
streamErr.write(`Comment id not found: ${commentId}\n`);
return 1;
}
const writeResult = await writeObservedNoteSafely(notePath, createContentFingerprint(noteContent), updated, {
settleMs: options.settleMs,
});
if (writeResult.kind === "changed") {
streamErr.write(
`Skipped resolving ${notePath} because ${writeResult.reason}. `
+ "Rerun after Obsidian Sync or other local edits settle.\n",
);
return 1;
}
streamOut.write(`Resolved comment ${commentId} in ${notePath}\n`);
return 0;
}
@ -984,6 +1394,65 @@ async function runInstallSkill(argv, streamOut, streamErr) {
return 0;
}
async function runUninstallAgentSupport(argv, streamOut, streamErr) {
let options;
try {
options = parseUninstallAgentSupportArgs(argv);
} catch (error) {
streamErr.write(`${error instanceof Error ? error.message : String(error)}\n`);
printUninstallAgentSupportUsage(streamErr);
return 1;
}
if (options === null) {
printUninstallAgentSupportUsage(streamOut);
return 0;
}
const vaultRoot = await resolveVaultRootForAgentSupport(options);
const vaultAgentsPath = path.join(vaultRoot, "AGENTS.md");
let agentsResult = "No AGENTS.md found.";
if (await pathExists(vaultAgentsPath)) {
const existingAgentsContent = await readFile(vaultAgentsPath, "utf8");
const stripResult = stripSideNote2ManagedAgentsContent(existingAgentsContent);
if (stripResult.kind === "removed") {
if (stripResult.nextContent.trim().length === 0) {
await rm(vaultAgentsPath, { force: true });
agentsResult = `Removed SideNote2 AGENTS.md content and deleted ${vaultAgentsPath}.`;
} else {
await writeFileAtomically(vaultAgentsPath, stripResult.nextContent);
agentsResult = `Removed the SideNote2-managed AGENTS block from ${vaultAgentsPath}.`;
}
} else {
agentsResult = `No SideNote2-managed AGENTS block found in ${vaultAgentsPath}.`;
}
}
const { skillDirectories } = await getBundledSkills();
const removedSkillNames = [];
for (const skillName of [...skillDirectories.keys()].sort((left, right) => left.localeCompare(right))) {
const destinationDir = path.join(options.skillsRoot, skillName);
if (!(await pathExists(destinationDir))) {
continue;
}
await rm(destinationDir, { recursive: true, force: true });
removedSkillNames.push(skillName);
}
streamOut.write(`${agentsResult}\n`);
if (removedSkillNames.length > 0) {
for (const skillName of removedSkillNames) {
streamOut.write(`Removed skill ${skillName} from ${path.join(options.skillsRoot, skillName)}\n`);
}
} else {
streamOut.write(`No bundled SideNote2 skills found under ${options.skillsRoot}\n`);
}
streamOut.write("Restart Codex to drop any cached skills.\n");
return 0;
}
export {
createContentFingerprint,
writeObservedNoteSafely,
@ -998,8 +1467,12 @@ export async function runCli(argv, io = { stdout: process.stdout, stderr: proces
return runCommentAppend(rest, io.stdout, io.stderr);
case "comment:update":
return runCommentUpdate(rest, io.stdout, io.stderr);
case "comment:resolve":
return runCommentResolve(rest, io.stdout, io.stderr);
case "install-skill":
return runInstallSkill(rest, io.stdout, io.stderr);
case "uninstall-agent-support":
return runUninstallAgentSupport(rest, io.stdout, io.stderr);
case "--help":
case "-h":
case undefined:

View file

@ -80,11 +80,19 @@ SIDENOTE2_HOT_RELOAD=0 npm run dev
- `npm run build` creates a production bundle.
- `npm test` runs the Node test suite.
- `npm run skill:install` copies the packaged SideNote2 Codex skills into the default Codex skills directory. By default it installs every bundled skill under `skills/`; pass `-- --name <skill-name>` to install just one. This matches the end-user install flow.
- `npm run skill:install` copies the packaged SideNote2 Codex skills into the default Codex skills directory. By default it installs every bundled top-level skill under `skills/`; pass `-- --name <skill-name>` to install just one. This is optional global Codex setup, not the default end-user flow inside Obsidian.
- `npm run comment:append -- --file "/abs/path/note.md" --id "<comment-id>" --comment-file "/abs/path/reply.md"` appends one new entry to an existing SideNote2 thread using the same managed block format as the plugin.
- `npm run comment:append -- --uri "obsidian://side-note2-comment?..." --comment-file "/abs/path/reply.md"` does the same when you already have a SideNote2 comment URI instead of a note path and comment id.
- `npm run comment:migrate-legacy -- --file "/abs/path/note.md" --dry-run` is a temporary maintenance fallback for a note that somehow missed the automatic `2.0.1` startup migration.
- `npm run comment:migrate-legacy -- --root "/abs/path/to/vault" --dry-run` is the same fallback at vault scope for out-of-band repair work, not the normal upgrade path.
- `npm run comment:resolve -- --file "/abs/path/note.md" --id "<comment-id>"` marks one stored SideNote2 thread resolved using the same managed block format as the plugin.
- `npm run comment:resolve -- --uri "obsidian://side-note2-comment?..."` resolves a stored SideNote2 thread directly from a SideNote2 comment URI.
- `npm run comment:update -- --file "/abs/path/note.md" --id "<comment-id>" --comment-file "/abs/path/comment.md"` updates one stored comment body using the same managed block format as the plugin.
- `npm run comment:update -- --uri "obsidian://side-note2-comment?..." --comment-file "/abs/path/comment.md"` updates a stored comment body directly from a SideNote2 comment URI.
- On startup, the plugin now ensures the vault root has SideNote2 agent instructions by creating `AGENTS.md` when missing, or inserting/updating a SideNote2-managed block inside an existing `AGENTS.md`. Existing user instructions stay intact outside that managed block.
- The plugin command `SideNote2: Sync AGENTS.md in vault root` creates, inserts, or updates the SideNote2-managed instructions in the active Obsidian vault root using the vault name and, on desktop, the vault base path from the Obsidian runtime API.
- The plugin command `SideNote2: Remove SideNote2 agent support from vault` removes the SideNote2-managed block from the active vault `AGENTS.md`, and deletes the file if that managed block was the only content.
- `npm run agent:uninstall -- --vault-root "/abs/path/to/vault"` removes the SideNote2-managed vault `AGENTS.md` block and uninstalls bundled SideNote2 Codex skills from the default skills root. Use `--skills-root` to target a different Codex skills directory, or `--vault "<vault-name>"` to resolve the vault through Obsidian config instead of an explicit path.
- The plugin auto-migrates legacy flat note comments once per vault on startup in `2.0.1`. Normal upgrades should not require any manual migration command.
- `comment:append`, `comment:migrate-legacy`, and `comment:update` now write atomically and refuse to overwrite a note if it changed after the script first read it. If Obsidian Sync or another editor is active, pass `-- --settle-ms 2000` to require a short quiet window before each write, then rerun any skipped notes after Sync settles. Treat skipped-note runs as partial success and retry them instead of hand-editing the managed JSON.
- `npm version patch|minor|major` updates `package.json`, `manifest.json`, `versions.json`, and the README beta badge together for a release bump.

View file

@ -11,8 +11,10 @@
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"comment:append": "node bin/sidenote2.mjs comment:append",
"comment:migrate-legacy": "node bin/sidenote2.mjs comment:migrate-legacy",
"comment:resolve": "node bin/sidenote2.mjs comment:resolve",
"comment:update": "node bin/sidenote2.mjs comment:update",
"fixture:graph-1000": "node scripts/generate-large-graph-fixture.mjs",
"agent:uninstall": "node bin/sidenote2.mjs uninstall-agent-support",
"skill:install": "node bin/sidenote2.mjs install-skill",
"test": "rm -rf .test-dist && tsc -p tsconfig.test.json && find .test-dist/tests -type f -name '*.test.js' -exec node --test {} + && find tests -type f -name '*.test.mjs' -exec node --test {} +",
"release:check": "npm run test && npm run build",

View file

@ -0,0 +1,5 @@
#!/usr/bin/env node
import { main } from "../bin/sidenote2.mjs";
await main(["comment:resolve", ...process.argv.slice(2)]);

View file

@ -28,9 +28,9 @@ description: Use when building, debugging, releasing, or submitting an Obsidian
- In this repo, treat the repo-local `skills/*/SKILL.md` files as the canonical agent instructions.
- Use this skill for general plugin repo, API, UI, build, and release work.
- When the task is about real SideNote2-backed vault notes, hidden `<!-- SideNote2 comments -->` blocks, or helper-script based note comment edits, switch to `skills/side-note2-note-comments/SKILL.md`.
- When the task is about real SideNote2-backed vault notes, `obsidian://side-note2-comment?...` links, hidden `<!-- SideNote2 comments -->` blocks, or helper-script based note comment edits, switch to `skills/sidenote2/SKILL.md`.
- Do not keep separate Claude-only and Codex-only copies of the same repo-local skill text unless the workflows genuinely diverge.
- If you need to test the packaged global install flow from this repo, run `npm run skill:install` or `npm run skill:install -- --name obsidian-plugin-dev`.
- If you need to test the packaged global install flow from this repo, run `npm run skill:install` or `npm run skill:install -- --name sidenote2`.
## Reference Sets

View file

@ -1,140 +0,0 @@
---
name: side-note2-note-comments
description: Use when reading or editing SideNote2-backed Obsidian notes. Covers finding the relevant markdown note in the vault, reading the note body plus trailing `<!-- SideNote2 comments -->` JSON block, identifying stored comments by `id` or `selectedText`, and using the repo helper script or direct note edits when the user asks the agent to write changes.
---
# SideNote2 Obsidian Notes
Use this skill when the user wants work done against real Obsidian notes that use SideNote2 note-backed comments.
## Scope
- Read note content directly from markdown files in the vault.
- Read SideNote2 comment data from the trailing `<!-- SideNote2 comments -->` block in the same note.
- Write note content directly when the user asks to edit the note body.
- Write SideNote2 comment bodies through the helper script when possible.
- Append new entries to an existing SideNote2 thread when the user says things like `reply`, `answer in the side comment`, `add another note under this`, or `add to thread`.
## Replacement Lookup Paths
Use one of these two paths to locate the side comment that should be replaced:
1. `SideNote2 index.md`
- Use this to discover the note path and comment target when the user refers to a side comment indirectly.
- Treat it as a jump index only, not canonical storage.
2. The users active note in source mode
- Use the trailing `<!-- SideNote2 comments -->` block at the bottom of the active markdown note.
- Treat this as the authoritative source when reading or writing a stored comment.
## Finding Notes
1. Prefer an explicit absolute path from the user.
2. Resolve the actual Obsidian vault root before searching broadly.
- Do not assume the plugin repo root is the vault root.
- If Obsidian CLI is available, check `obsidian vaults verbose` to map vault names to paths.
- Check `~/.config/obsidian/obsidian.json` for vault entries marked `"open": true` when you need the vault(s) currently open in Obsidian.
- Check `<vault>/.obsidian/workspace.json` for the current workspace state, active leaves, and recent files when you need the note the user is actively working in.
- If the repo is nested inside a larger vault, search the outer vault root for notes and keep the repo root only for helper scripts.
3. If the user gives only a note title or fragment, search the resolved vault root for matching `.md` files.
4. Treat the markdown note itself as the source of truth.
5. Do not rely on the Obsidian UI state alone when the note file can be read directly.
## Reading Workflow
1. Confirm the target note path.
2. If needed, use `SideNote2 index.md` to find the relevant note path first.
3. Open the note itself, not just plugin code or generated index files.
4. Read both:
- the main markdown content
- the trailing `<!-- SideNote2 comments -->` block, if present
5. When the user asks about a specific side note:
- match by `id` if available
- otherwise match by `selectedText` and surrounding context
- do not rely on `timestamp` if a stronger identifier exists
## Writing Workflow
If the user asks to edit the note body:
1. Edit the markdown note directly.
2. Preserve the trailing SideNote2 managed block unless the task explicitly changes comments.
If the user asks to add a page-level tag in a SideNote2 note:
1. Inspect the note's leading YAML frontmatter only if it starts at the top of the file.
2. If that frontmatter already contains a `tags` field, add the tag there and avoid duplicates.
3. Preserve the note's existing `tags` style when practical, such as a YAML list versus inline form.
4. If the note does not have a leading frontmatter `tags` field, do not create one just for this request.
5. In that fallback case, add the tag as a SideNote2 page note instead.
If the user asks to reply to a side comment, answer a question in a side comment, add another note under a side comment, add to thread, or split one longer side comment into several short notes in the same place:
1. Treat this as an append-to-thread request.
2. Do not overwrite the existing comment body unless the user explicitly asks to edit or rewrite that entry.
3. Do not create a separate markdown file just to continue the same side comment thread.
4. Confirm the target note path.
5. Identify the target thread by `id` when it is available.
- If `id` is not provided, match by `selectedText` and surrounding note context.
- If multiple stored comments in the same note share the same `selectedText`, ask for more context or use the `id`.
6. Prefer the append helper script from the repo root:
```bash
cd "/abs/path/to/SideNote2"
node scripts/append-note-comment-entry.mjs --file "/abs/path/to/note.md" --id "<comment-id>" --comment-file "/abs/path/to/reply.md"
```
Short replies can use `--comment "Reply body"` instead of `--comment-file`.
If Sync is active, add `--settle-ms 2000` here too so the script skips notes that changed after it read them instead of overwriting them.
7. If the note is outside the writable workspace, request escalation before running the script.
8. If the helper script cannot be used, append one new object to the end of the target thread's `entries[]` array and preserve all existing entries.
9. Use this pattern for:
- answering a question the user wrote inside an existing side comment
- splitting one comment into multiple shorter thread entries for readability, similar to a short post thread
10. Verify that only the target thread changed and that exactly one new entry was appended.
If the user asks to edit a stored SideNote2 comment:
Use this workflow only when the user wants to replace or rewrite an existing stored comment body. If the user says `reply`, `add another note under`, `continue this`, or similar, use the append-to-thread workflow above instead.
1. Confirm the target note path.
2. If needed, use `SideNote2 index.md` to locate the target note and comment first.
3. Inspect the trailing `<!-- SideNote2 comments -->` block in the active markdown note.
4. Identify the target comment by `id` when it is available.
- Natural-language requests such as `Update the side comment for "selected text" in "/path/to/note.md" to: ...` should be interpreted as a `selectedText`-based replacement request.
- Natural-language requests such as `Reply to the side comment for "selected text"` or `Add another note under this side comment` are append-to-thread requests, not replacement requests.
- If `id` is not provided, match by `selectedText` and surrounding note context.
- If multiple stored comments in the same note share the same `selectedText`, ask for more context or use the `id`.
5. Prefer the helper script from the repo root:
```bash
cd "/abs/path/to/SideNote2"
node scripts/update-note-comment.mjs --file "/abs/path/to/note.md" --id "<comment-id>" --comment-file "/abs/path/to/comment.md"
```
Short replacements can use `--comment "New body"` instead of `--comment-file`.
If Sync is active, add `--settle-ms 2000` here too so the script skips notes that changed after it read them instead of overwriting them.
6. If the note is outside the writable workspace, request escalation before running the script.
7. Verify the note still contains exactly one managed block and that only the target comment thread or entry body changed.
## Important Details
- SideNote2 stores comments as strict JSON in the trailing hidden block.
- Treat threaded `entries[]` storage as canonical. Starting in SideNote2 `2.0.1`, the plugin auto-migrates older flat note-comment payloads on startup, so agents should not build manual legacy migration steps into normal workflows.
- In threaded storage, `entries[]` order is meaningful. Replies and `add another note under this` requests should usually append one new entry at the end.
- Use `scripts/append-note-comment-entry.mjs` for append-to-thread requests and `scripts/update-note-comment.mjs` only for replacement requests.
- The helper scripts write atomically and skip notes that changed after the initial read. Treat a skipped note as a retry case, not as a signal to hand-edit the managed JSON.
- Multiline comment bodies must stay JSON-escaped in source; do not paste raw block text into the JSON string by hand unless necessary.
- The note itself is the source of truth. Sidebar state and aggregate views are derived from the note.
- `SideNote2 index.md` is generated output, not canonical storage.
## Fallback
If the helper script cannot be used:
1. Edit the source-mode block directly.
2. Preserve `id`, anchor coordinates, `selectedText`, `selectedTextHash`, timestamps, and `resolved`.
3. For replacement requests, change only the target `entries[*].body`.
4. For append-to-thread requests, append one new object to the target thread's `entries[]` array without modifying the existing entries.
5. If a note still shows an old flat `comment` payload unexpectedly, stop and ask the user to open the vault once in SideNote2 `2.0.1+` so the startup migration can finish instead of hand-migrating JSON in the agent workflow.

95
skills/sidenote2/SKILL.md Normal file
View file

@ -0,0 +1,95 @@
---
name: sidenote2
description: Use when a user is working with SideNote2 comments in real Obsidian notes, especially if they paste an `obsidian://side-note2-comment?...` link or ask to reply to, update, or inspect a stored side note thread.
---
# SideNote2
Use this as the user-facing SideNote2 skill.
This skill exists so an agent can recognize normal user phrasing without requiring the user to know internal repo skill names.
## Trigger Phrases
Use this skill when the user:
- pastes an `obsidian://side-note2-comment?...` URI
- says `reply to this`
- says `answer this side note`
- says `add another note under this`
- says `update this side note`
- says `resolve this side note`
- says `edit this stored comment`
- gives a `commentId` and wants to act on a SideNote2 thread
## Source Of Truth
- The markdown note itself is canonical.
- SideNote2 stores comments in the trailing `<!-- SideNote2 comments -->` block in that note.
- `SideNote2 index.md` is derived output. Use it to discover a note path, not as canonical storage.
## Working Rules
1. If the user provided an `obsidian://side-note2-comment?...` URI:
- treat it as the exact thread target
- prefer the URI-based CLI path instead of re-discovering the note manually
2. If no URI is provided, locate the real markdown note and read the trailing SideNote2 managed block.
3. Distinguish:
- `reply`, `continue`, `answer this`, `add another note under this`
append to the thread
- `update`, `rewrite`, `replace`
replace the targeted stored comment body
- `resolve`, `mark resolved`, `archive this side note`
mark the targeted thread resolved
4. Prefer helper scripts over hand-editing JSON.
5. Preserve all existing thread entries unless the user explicitly asked to replace one.
## Preferred CLI Shapes
Append:
```bash
node scripts/append-note-comment-entry.mjs --uri "obsidian://side-note2-comment?..." --comment-file /abs/path/reply.md
```
Or with an explicit file and comment id:
```bash
node scripts/append-note-comment-entry.mjs --file /abs/path/note.md --id "<comment-id>" --comment-file /abs/path/reply.md
```
Update:
```bash
node scripts/update-note-comment.mjs --uri "obsidian://side-note2-comment?..." --comment-file /abs/path/comment.md
```
Or with an explicit file and comment id:
```bash
node scripts/update-note-comment.mjs --file /abs/path/note.md --id "<comment-id>" --comment-file /abs/path/comment.md
```
Resolve:
```bash
node scripts/resolve-note-comment.mjs --uri "obsidian://side-note2-comment?..."
```
Or with an explicit file and comment id:
```bash
node scripts/resolve-note-comment.mjs --file /abs/path/note.md --id "<comment-id>"
```
## Matching Rules
- Match by `commentId` when available.
- Otherwise match by `selectedText` plus nearby note context.
- If multiple stored comments share the same `selectedText`, ask for more context or use the URI/comment id.
## Safety
- Do not overwrite a thread when the user asked to reply.
- Do not hand-migrate legacy flat `comment` payloads during normal agent work.
- If the helper script refuses a note because it changed after read, treat it as a retry case instead of editing the JSON manually.

View file

@ -31,7 +31,8 @@ export interface PluginRegistrationHost {
id: string;
name: string;
icon: string;
editorCallback: (
callback?: () => Promise<void> | void;
editorCallback?: (
editor: EditorSelectionLike,
view: EditorCommandViewLike,
) => Promise<void> | void;
@ -48,6 +49,8 @@ export interface PluginRegistrationHost {
startDraftFromEditorSelection(editor: EditorSelectionLike, file: TFile | null): Promise<unknown>;
highlightCommentById(filePath: string, commentId: string): Promise<void>;
openIndexNote(): Promise<void> | void;
installVaultAgentsFile(): Promise<void>;
uninstallVaultAgentsFile(): Promise<void>;
}
export interface CommentProtocolTarget {
@ -85,6 +88,24 @@ export class PluginRegistrationController {
},
});
this.host.addCommand({
id: "install-vault-agents-file",
name: "Sync AGENTS.md in vault root",
icon: this.host.iconId,
callback: async () => {
await this.host.installVaultAgentsFile();
},
});
this.host.addCommand({
id: "uninstall-vault-agents-file",
name: "Remove SideNote2 agent support from vault",
icon: this.host.iconId,
callback: async () => {
await this.host.uninstallVaultAgentsFile();
},
});
this.host.registerEditorMenu((menu, editor, view) => {
if (!editor.somethingSelected()) {
return;

View file

@ -0,0 +1,339 @@
export interface VaultAgentsFileContext {
vaultName: string;
vaultRootPath: string | null;
pluginVersion: string;
}
export interface VaultAgentsFileHost {
getVaultAgentsFileContext(): VaultAgentsFileContext;
vaultRootFileExists(relativePath: string): Promise<boolean>;
readVaultRootFile(relativePath: string): Promise<string>;
writeVaultRootFile(relativePath: string, content: string): Promise<void>;
deleteVaultRootFile(relativePath: string): Promise<void>;
showNotice(message: string): void;
warn(message: string, error: unknown): void;
}
const VAULT_AGENTS_FILE_PATH = "AGENTS.md";
const MANAGED_BLOCK_START_PREFIX = "<!-- SideNote2 managed AGENTS start";
const MANAGED_BLOCK_END = "<!-- SideNote2 managed AGENTS end -->";
export type VaultAgentsSyncMode = "manual" | "startup";
export type VaultAgentsSyncPlan =
| {
kind: "write";
nextContent: string;
reason: "created" | "updated" | "inserted";
}
| {
kind: "noop";
reason: "already-current";
};
type VaultAgentsWriteReason = Extract<VaultAgentsSyncPlan, { kind: "write" }>["reason"];
type VaultAgentsRemovalPlan =
| {
kind: "write";
nextContent: string;
}
| {
kind: "delete";
}
| {
kind: "noop";
};
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, "\n");
}
function buildVaultAgentsInstructions(context: VaultAgentsFileContext): string {
const lines = [
"# SideNote2 Vault Agent Routing",
"",
`This Obsidian vault is \`${context.vaultName}\`.`,
];
if (context.vaultRootPath) {
lines.push(`Vault root path: \`${context.vaultRootPath}\`.`);
}
lines.push(
"",
"When a user is working with real SideNote2 comments in this vault:",
"",
"- Treat the markdown note as the source of truth.",
"- Treat the trailing `<!-- SideNote2 comments -->` block as the canonical stored comment data.",
"- Treat `SideNote2 index.md` as discovery output, not canonical storage.",
"",
"If the `sidenote2` skill is available, use it for:",
"",
"- `obsidian://side-note2-comment?...` URIs",
"- `reply to this`",
"- `add another note under this`",
"- `update this side note`",
"- `resolve this side note`",
"- `edit this stored comment`",
"",
"Intent mapping:",
"",
"- `reply`, `continue`, `answer this`, `add another note under this`",
" append to the existing thread",
"- `update`, `rewrite`, `replace this comment`",
" replace the targeted stored comment body",
"- `resolve`, `mark resolved`, `archive this side note`",
" mark the targeted thread resolved",
"",
"Preferred write path when the local `sidenote2` CLI is available:",
"",
"```bash",
"sidenote2 comment:append --uri \"obsidian://side-note2-comment?...\" --comment-file /abs/path/reply.md",
"sidenote2 comment:resolve --uri \"obsidian://side-note2-comment?...\"",
"sidenote2 comment:update --uri \"obsidian://side-note2-comment?...\" --comment-file /abs/path/comment.md",
"```",
"",
"If the CLI is not available, edit the source markdown note carefully and preserve all existing thread entries unless the user explicitly asked to replace one.",
"",
);
return `${lines.join("\n")}\n`;
}
export function buildVaultAgentsFileContent(context: VaultAgentsFileContext): string {
return `${MANAGED_BLOCK_START_PREFIX} version="${context.pluginVersion}" -->\n${buildVaultAgentsInstructions(context)}${MANAGED_BLOCK_END}\n`;
}
function findManagedBlockRange(content: string): { start: number; end: number } | null {
const normalizedContent = normalizeLineEndings(content);
const start = normalizedContent.indexOf(MANAGED_BLOCK_START_PREFIX);
if (start === -1) {
return null;
}
const endMarkerStart = normalizedContent.indexOf(MANAGED_BLOCK_END, start);
if (endMarkerStart === -1) {
return null;
}
let end = endMarkerStart + MANAGED_BLOCK_END.length;
if (normalizedContent.charAt(end) === "\n") {
end += 1;
}
return { start, end };
}
function isLegacyManagedVaultAgentsFileContent(content: string): boolean {
const normalizedContent = normalizeLineEndings(content).trim();
return normalizedContent.startsWith("# SideNote2 Vault Agent Routing")
&& normalizedContent.includes("When a user is working with real SideNote2 comments in this vault:")
&& normalizedContent.includes("obsidian://side-note2-comment?...");
}
function appendManagedBlockToDocument(existingContent: string, managedBlock: string): string {
const trimmedContent = normalizeLineEndings(existingContent).replace(/\s+$/u, "");
if (!trimmedContent) {
return managedBlock;
}
return `${trimmedContent}\n\n${managedBlock}`;
}
function removeManagedBlockFromDocument(existingContent: string, blockRange: { start: number; end: number }): string {
const normalizedContent = normalizeLineEndings(existingContent);
const before = normalizedContent.slice(0, blockRange.start).replace(/\s+$/u, "");
const after = normalizedContent.slice(blockRange.end).replace(/^\s+/u, "");
if (!before && !after) {
return "";
}
if (!before) {
return `${after}\n`;
}
if (!after) {
return `${before}\n`;
}
return `${before}\n\n${after}\n`;
}
export function planVaultAgentsFileSync(
existingContent: string | null,
context: VaultAgentsFileContext,
_mode: VaultAgentsSyncMode,
): VaultAgentsSyncPlan {
const nextManagedContent = buildVaultAgentsFileContent(context);
if (existingContent === null) {
return {
kind: "write",
nextContent: nextManagedContent,
reason: "created",
};
}
const normalizedExistingContent = normalizeLineEndings(existingContent);
const managedBlockRange = findManagedBlockRange(normalizedExistingContent);
if (managedBlockRange) {
const currentManagedBlock = normalizedExistingContent.slice(managedBlockRange.start, managedBlockRange.end);
if (currentManagedBlock === nextManagedContent) {
return {
kind: "noop",
reason: "already-current",
};
}
return {
kind: "write",
nextContent: `${normalizedExistingContent.slice(0, managedBlockRange.start)}${nextManagedContent}${normalizedExistingContent.slice(managedBlockRange.end)}`,
reason: "updated",
};
}
if (isLegacyManagedVaultAgentsFileContent(normalizedExistingContent)) {
return {
kind: "write",
nextContent: nextManagedContent,
reason: "updated",
};
}
return {
kind: "write",
nextContent: appendManagedBlockToDocument(normalizedExistingContent, nextManagedContent),
reason: "inserted",
};
}
export function planVaultAgentsFileRemoval(existingContent: string | null): VaultAgentsRemovalPlan {
if (existingContent === null) {
return { kind: "noop" };
}
const normalizedExistingContent = normalizeLineEndings(existingContent);
const managedBlockRange = findManagedBlockRange(normalizedExistingContent);
if (managedBlockRange) {
const nextContent = removeManagedBlockFromDocument(normalizedExistingContent, managedBlockRange);
return nextContent.trim().length > 0
? { kind: "write", nextContent }
: { kind: "delete" };
}
if (isLegacyManagedVaultAgentsFileContent(normalizedExistingContent)) {
return { kind: "delete" };
}
return { kind: "noop" };
}
export class VaultAgentsFileController {
constructor(private readonly host: VaultAgentsFileHost) {}
public async installVaultAgentsFile(): Promise<void> {
await this.syncVaultAgentsFile("manual");
}
public async uninstallVaultAgentsFile(): Promise<void> {
const context = this.host.getVaultAgentsFileContext();
const location = this.describeLocation(context);
let existingContent: string | null = null;
try {
if (await this.host.vaultRootFileExists(VAULT_AGENTS_FILE_PATH)) {
existingContent = await this.host.readVaultRootFile(VAULT_AGENTS_FILE_PATH);
}
} catch (error) {
this.host.warn("Failed to read AGENTS.md from the vault root.", error);
this.host.showNotice("Failed to inspect AGENTS.md in the vault root.");
return;
}
const plan = planVaultAgentsFileRemoval(existingContent);
if (plan.kind === "noop") {
this.host.showNotice(`No SideNote2 AGENTS instructions found in ${location}`);
return;
}
try {
if (plan.kind === "delete") {
await this.host.deleteVaultRootFile(VAULT_AGENTS_FILE_PATH);
} else {
await this.host.writeVaultRootFile(VAULT_AGENTS_FILE_PATH, plan.nextContent);
}
} catch (error) {
this.host.warn("Failed to remove SideNote2 AGENTS instructions from the vault root.", error);
this.host.showNotice("Failed to remove SideNote2 AGENTS instructions from the vault root.");
return;
}
this.host.showNotice(
plan.kind === "delete"
? `Removed SideNote2 AGENTS.md from ${location}`
: `Removed SideNote2 AGENTS instructions from ${location}`,
);
}
public async syncVaultAgentsFileOnStartup(): Promise<void> {
await this.syncVaultAgentsFile("startup");
}
private async syncVaultAgentsFile(mode: VaultAgentsSyncMode): Promise<void> {
const context = this.host.getVaultAgentsFileContext();
const location = this.describeLocation(context);
let existingContent: string | null = null;
try {
if (await this.host.vaultRootFileExists(VAULT_AGENTS_FILE_PATH)) {
existingContent = await this.host.readVaultRootFile(VAULT_AGENTS_FILE_PATH);
}
} catch (error) {
this.host.warn("Failed to read AGENTS.md from the vault root.", error);
if (mode === "manual") {
this.host.showNotice("Failed to inspect AGENTS.md in the vault root.");
}
return;
}
const plan = planVaultAgentsFileSync(existingContent, context, mode);
if (plan.kind === "noop") {
if (mode === "manual") {
this.host.showNotice(`SideNote2 AGENTS instructions are already up to date in ${location}`);
}
return;
}
try {
await this.host.writeVaultRootFile(VAULT_AGENTS_FILE_PATH, plan.nextContent);
} catch (error) {
this.host.warn("Failed to write AGENTS.md into the vault root.", error);
if (mode === "manual") {
this.host.showNotice("Failed to install AGENTS.md in the vault root.");
}
return;
}
if (mode === "manual") {
this.host.showNotice(this.buildManualSuccessNotice(plan.reason, location));
}
}
private describeLocation(context: VaultAgentsFileContext): string {
return context.vaultRootPath
? `${context.vaultRootPath}/AGENTS.md`
: `the vault root for ${context.vaultName}`;
}
private buildManualSuccessNotice(reason: VaultAgentsWriteReason, location: string): string {
switch (reason) {
case "created":
return `Installed AGENTS.md in ${location}`;
case "updated":
return `Updated SideNote2 AGENTS instructions in ${location}`;
case "inserted":
return `Inserted SideNote2 AGENTS instructions into existing ${location}`;
}
}
}

View file

@ -821,3 +821,41 @@ export function appendNoteCommentEntryById(
return serializeNoteCommentThreads(noteContent, updatedThreads);
}
export function setNoteCommentResolvedById(
noteContent: string,
filePath: string,
commentId: string,
nextResolved: boolean,
): string | null {
const parsed = parseNoteComments(noteContent, filePath);
let found = false;
const updatedThreads = parsed.threads.map((thread) => {
const matchesThread = thread.id === commentId
|| thread.entries.some((entry) => entry.id === commentId);
if (!matchesThread) {
return thread;
}
found = true;
return {
...thread,
resolved: nextResolved,
};
});
if (!found) {
return null;
}
return serializeNoteCommentThreads(noteContent, updatedThreads);
}
export function resolveNoteCommentById(
noteContent: string,
filePath: string,
commentId: string,
): string | null {
return setNoteCommentResolvedById(noteContent, filePath, commentId, true);
}

View file

@ -1,4 +1,4 @@
import { addIcon, WorkspaceLeaf, TFile, Notice, Plugin, normalizePath, MarkdownView, type Editor } from "obsidian";
import { addIcon, WorkspaceLeaf, TFile, Notice, Plugin, normalizePath, MarkdownView, FileSystemAdapter, type Editor } from "obsidian";
import type { EditorView } from "@codemirror/view";
import { Comment, CommentManager, CommentThread } from "./commentManager";
import { CommentEntryController } from "./control/commentEntryController";
@ -14,6 +14,7 @@ import { PluginLifecycleController } from "./control/pluginLifecycleController";
import { PluginRegistrationController } from "./control/pluginRegistrationController";
import { WorkspaceContextController } from "./control/workspaceContextController";
import { WorkspaceViewController } from "./control/workspaceViewController";
import { VaultAgentsFileController } from "./control/vaultAgentsFileController";
import { DraftComment } from "./domain/drafts";
import { parsePromptDeleteSetting } from "./core/config/appConfig";
import { DerivedCommentMetadataManager } from "./core/derived/derivedCommentMetadata";
@ -225,6 +226,25 @@ export default class SideNote2 extends Plugin {
console.warn(message, error);
},
});
private readonly vaultAgentsFileController = new VaultAgentsFileController({
getVaultAgentsFileContext: () => ({
vaultName: this.app.vault.getName(),
vaultRootPath: this.app.vault.adapter instanceof FileSystemAdapter
? this.app.vault.adapter.getBasePath()
: null,
pluginVersion: this.manifest.version,
}),
vaultRootFileExists: (relativePath) => this.app.vault.adapter.exists(relativePath),
readVaultRootFile: (relativePath) => this.app.vault.adapter.read(relativePath),
writeVaultRootFile: (relativePath, content) => this.app.vault.adapter.write(relativePath, content),
deleteVaultRootFile: (relativePath) => this.app.vault.adapter.remove(relativePath),
showNotice: (message) => {
new Notice(message);
},
warn: (message, error) => {
console.warn(message, error);
},
});
private readonly pluginRegistrationController = new PluginRegistrationController({
manifestId: this.manifest.id,
iconId: SIDE_NOTE2_ICON_ID,
@ -251,6 +271,8 @@ export default class SideNote2 extends Plugin {
this.commentEntryController.startDraftFromEditorSelection(editor as unknown as Editor, file),
highlightCommentById: (filePath, commentId) => this.highlightCommentById(filePath, commentId),
openIndexNote: () => this.openIndexNote(),
installVaultAgentsFile: () => this.installVaultAgentsFile(),
uninstallVaultAgentsFile: () => this.uninstallVaultAgentsFile(),
});
private readonly workspaceContextController = new WorkspaceContextController({
app: this.app,
@ -295,10 +317,12 @@ export default class SideNote2 extends Plugin {
if (this.app.workspace.layoutReady) {
await this.pluginLifecycleController.handleLayoutReady();
await this.legacyNoteCommentsMigrationController.runStartupMigrationIfNeeded();
await this.vaultAgentsFileController.syncVaultAgentsFileOnStartup();
} else {
this.app.workspace.onLayoutReady(async () => {
await this.pluginLifecycleController.handleLayoutReady();
await this.legacyNoteCommentsMigrationController.runStartupMigrationIfNeeded();
await this.vaultAgentsFileController.syncVaultAgentsFileOnStartup();
});
}
@ -580,6 +604,14 @@ export default class SideNote2 extends Plugin {
await this.commentNavigationController.openCommentById(filePath, commentId);
}
private async installVaultAgentsFile(): Promise<void> {
await this.vaultAgentsFileController.installVaultAgentsFile();
}
private async uninstallVaultAgentsFile(): Promise<void> {
await this.vaultAgentsFileController.uninstallVaultAgentsFile();
}
public getDraftForFile(filePath: string): DraftComment | null {
return this.commentSessionController.getDraftForFile(filePath);
}

View file

@ -1,6 +1,6 @@
import * as assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
@ -10,6 +10,22 @@ import type { Comment } from "../src/commentManager";
const execFile = promisify(execFileCallback);
async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise<void> {
const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json");
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify({
vaults: {
"vault-1": {
path: vaultRoot,
},
},
}, null, 2), "utf8");
}
function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string {
return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`;
}
function createComment(overrides: Partial<Comment> = {}): Comment {
return {
id: "comment-1",
@ -60,3 +76,42 @@ test("append-note-comment-entry script appends a new entry to the targeted threa
assert.equal(parsed.threads[0].entries[1].body, "Reply body\nSecond line");
assert.equal(parsed.mainContent, "# Title\n\nBody text.");
});
test("append-note-comment-entry script can target a thread by obsidian side-note URI", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-uri-append-script-"));
const homeDir = path.join(tempDir, "home");
const vaultRoot = path.join(tempDir, "Public Vault");
const notePath = path.join(vaultRoot, "Folder", "Note.md");
const commentPath = path.join(tempDir, "reply.md");
const scriptPath = path.resolve(process.cwd(), "scripts/append-note-comment-entry.mjs");
const noteFilePath = "Folder/Note.md";
const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({
filePath: noteFilePath,
})]);
await mkdir(path.dirname(notePath), { recursive: true });
await writeObsidianVaultConfig(homeDir, vaultRoot);
await writeFile(notePath, original, "utf8");
await writeFile(commentPath, "Reply from URI\nSecond line\n", "utf8");
const { stdout } = await execFile("node", [
scriptPath,
"--uri",
buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"),
"--comment-file",
commentPath,
], {
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
},
});
assert.match(stdout, /Appended a new entry to comment comment-1/);
const updated = await readFile(notePath, "utf8");
const parsed = parseNoteComments(updated, notePath);
assert.equal(parsed.threads[0].entries.length, 2);
assert.equal(parsed.threads[0].entries[1].body, "Reply from URI\nSecond line");
});

View file

@ -9,6 +9,7 @@ import {
getManagedSectionStartLine,
getVisibleNoteContent,
parseNoteComments,
resolveNoteCommentById,
replaceNoteCommentBodyById,
serializeNoteComments,
} from "../src/core/storage/noteCommentStorage";
@ -218,6 +219,49 @@ test("appendNoteCommentEntryById returns null when the target id is missing", ()
}), null);
});
test("resolveNoteCommentById marks the targeted thread resolved", () => {
const original = serializeNoteComments("# Title\n\nAlpha beta gamma.\n", [
createComment({
id: "comment-1",
resolved: false,
}),
]);
const updated = resolveNoteCommentById(original, "note.md", "comment-1");
assert.ok(updated);
const parsed = parseNoteComments(updated, "note.md");
assert.equal(parsed.comments[0].resolved, true);
assert.equal(parsed.threads[0].resolved, true);
assert.match(updated, /"resolved": true/);
});
test("resolveNoteCommentById can target a child entry id and resolves the whole thread", () => {
const original = appendNoteCommentEntryById(serializeNoteComments("# Title\n\nAlpha beta gamma.\n", [
createComment({
id: "comment-1",
resolved: false,
}),
]), "note.md", "comment-1", {
id: "entry-2",
body: "Follow up",
timestamp: 1710000001000,
});
assert.ok(original);
const updated = resolveNoteCommentById(original, "note.md", "entry-2");
assert.ok(updated);
const parsed = parseNoteComments(updated, "note.md");
assert.equal(parsed.comments[0].resolved, true);
assert.equal(parsed.threads[0].resolved, true);
});
test("resolveNoteCommentById returns null when the target id is missing", () => {
const original = serializeNoteComments("Body\n", [createComment()]);
assert.equal(resolveNoteCommentById(original, "note.md", "missing-id"), null);
});
test("buildLegacyNoteCommentMigrationPlan converts legacy flat comments to threaded storage", () => {
const legacyNote = [
"# Title",

View file

@ -67,7 +67,8 @@ function createHarness() {
id: string;
name: string;
icon: string;
editorCallback: (editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => Promise<void> | void;
callback?: () => Promise<void> | void;
editorCallback?: (editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => Promise<void> | void;
}> = [];
let editorMenuHandler: ((menu: EditorMenuLike, editor: { somethingSelected(): boolean }, view: { file: TFile | null }) => void) | null = null;
const ribbonActions: Array<{ icon: string; title: string; callback: () => void }> = [];
@ -75,6 +76,8 @@ function createHarness() {
const draftCalls: Array<{ selected: boolean; filePath: string | null }> = [];
const highlightedCommentTargets: Array<{ filePath: string; commentId: string }> = [];
let openIndexNoteCount = 0;
let installVaultAgentsFileCount = 0;
let uninstallVaultAgentsFileCount = 0;
const controller = new PluginRegistrationController({
manifestId: "side-note2",
@ -113,6 +116,12 @@ function createHarness() {
openIndexNote: async () => {
openIndexNoteCount += 1;
},
installVaultAgentsFile: async () => {
installVaultAgentsFileCount += 1;
},
uninstallVaultAgentsFile: async () => {
uninstallVaultAgentsFileCount += 1;
},
});
return {
@ -127,6 +136,8 @@ function createHarness() {
draftCalls,
highlightedCommentTargets,
getOpenIndexNoteCount: () => openIndexNoteCount,
getInstallVaultAgentsFileCount: () => installVaultAgentsFileCount,
getUninstallVaultAgentsFileCount: () => uninstallVaultAgentsFileCount,
};
}
@ -140,10 +151,14 @@ test("plugin registration controller registers the view, protocol handler, comma
assert.equal(harness.registerViewCalls[0].creator({ id: "leaf-1" }) instanceof Object, true);
assert.deepEqual(harness.createdSidebarLeaves, [{ id: "leaf-1" }]);
assert.deepEqual(harness.removedCommandIds, ["side-note2:activate-view"]);
assert.deepEqual(harness.commands.map((command) => command.id), ["add-comment-to-selection"]);
assert.deepEqual(harness.commands.map((command) => command.id), [
"add-comment-to-selection",
"install-vault-agents-file",
"uninstall-vault-agents-file",
]);
assert.deepEqual(harness.ribbonActions.map((action) => action.title), ["Open SideNote2 index"]);
await harness.commands[0].editorCallback(
await harness.commands[0].editorCallback?.(
{ somethingSelected: () => true },
{ file: editorFile },
);
@ -166,6 +181,12 @@ test("plugin registration controller registers the view, protocol handler, comma
harness.ribbonActions[0].callback();
await Promise.resolve();
assert.equal(harness.getOpenIndexNoteCount(), 1);
await harness.commands[1].callback?.();
assert.equal(harness.getInstallVaultAgentsFileCount(), 1);
await harness.commands[2].callback?.();
assert.equal(harness.getUninstallVaultAgentsFileCount(), 1);
});
test("plugin registration controller only adds the editor menu item for active selections", async () => {

View file

@ -0,0 +1,106 @@
import * as assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { parseNoteComments, serializeNoteComments } from "../src/core/storage/noteCommentStorage";
import type { Comment } from "../src/commentManager";
const execFile = promisify(execFileCallback);
async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise<void> {
const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json");
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify({
vaults: {
"vault-1": {
path: vaultRoot,
},
},
}, null, 2), "utf8");
}
function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string {
return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`;
}
function createComment(overrides: Partial<Comment> = {}): Comment {
return {
id: "comment-1",
filePath: "note.md",
startLine: 1,
startChar: 2,
endLine: 1,
endChar: 7,
selectedText: "hello",
selectedTextHash: "hash-1",
comment: "Original body",
timestamp: 1710000000000,
resolved: false,
...overrides,
};
}
test("comment:resolve marks the targeted thread resolved", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-resolve-script-"));
const notePath = path.join(tempDir, "note.md");
const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment()]);
await writeFile(notePath, original, "utf8");
const { stdout } = await execFile("node", [
scriptPath,
"comment:resolve",
"--file",
notePath,
"--id",
"comment-1",
], {
cwd: process.cwd(),
});
assert.match(stdout, /Resolved comment comment-1/);
const updated = await readFile(notePath, "utf8");
const parsed = parseNoteComments(updated, notePath);
assert.equal(parsed.comments[0].resolved, true);
assert.equal(parsed.threads[0].resolved, true);
});
test("comment:resolve can target a stored comment by obsidian side-note URI", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-resolve-uri-script-"));
const homeDir = path.join(tempDir, "home");
const vaultRoot = path.join(tempDir, "Public Vault");
const notePath = path.join(vaultRoot, "Folder", "Note.md");
const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const noteFilePath = "Folder/Note.md";
const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({
filePath: noteFilePath,
})]);
await mkdir(path.dirname(notePath), { recursive: true });
await writeObsidianVaultConfig(homeDir, vaultRoot);
await writeFile(notePath, original, "utf8");
const { stdout } = await execFile("node", [
scriptPath,
"comment:resolve",
"--uri",
buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"),
], {
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
},
});
assert.match(stdout, /Resolved comment comment-1/);
const updated = await readFile(notePath, "utf8");
const parsed = parseNoteComments(updated, notePath);
assert.equal(parsed.comments[0].resolved, true);
});

View file

@ -0,0 +1,91 @@
import * as assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { buildVaultAgentsFileContent } from "../src/control/vaultAgentsFileController";
const execFile = promisify(execFileCallback);
test("uninstall-agent-support removes the SideNote2-managed AGENTS block and bundled skills", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-agent-uninstall-"));
const vaultRoot = path.join(tempDir, "vault");
const skillsRoot = path.join(tempDir, "skills");
const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const agentsPath = path.join(vaultRoot, "AGENTS.md");
await mkdir(vaultRoot, { recursive: true });
await mkdir(path.join(skillsRoot, "sidenote2"), { recursive: true });
await mkdir(path.join(skillsRoot, "canvas-design"), { recursive: true });
await mkdir(path.join(skillsRoot, "other-skill"), { recursive: true });
await writeFile(path.join(skillsRoot, "sidenote2", "SKILL.md"), "skill one", "utf8");
await writeFile(path.join(skillsRoot, "canvas-design", "SKILL.md"), "skill two", "utf8");
await writeFile(path.join(skillsRoot, "other-skill", "SKILL.md"), "keep me", "utf8");
const managedAgentsContent = buildVaultAgentsFileContent({
vaultName: "public",
vaultRootPath: vaultRoot,
pluginVersion: "2.0.2",
});
await writeFile(agentsPath, `# User Rules\n\nKeep answers brief.\n\n${managedAgentsContent}`, "utf8");
const { stdout } = await execFile("node", [
cliPath,
"uninstall-agent-support",
"--vault-root",
vaultRoot,
"--skills-root",
skillsRoot,
], {
cwd: process.cwd(),
});
assert.match(stdout, /Removed the SideNote2-managed AGENTS block/);
assert.match(stdout, /Removed skill sidenote2/);
assert.match(stdout, /Removed skill canvas-design/);
assert.match(stdout, /Restart Codex to drop any cached skills\./);
const nextAgentsContent = await readFile(agentsPath, "utf8");
assert.match(nextAgentsContent, /# User Rules/);
assert.match(nextAgentsContent, /Keep answers brief\./);
assert.doesNotMatch(nextAgentsContent, /SideNote2 Vault Agent Routing/);
await assert.rejects(access(path.join(skillsRoot, "sidenote2")));
await assert.rejects(access(path.join(skillsRoot, "canvas-design")));
await access(path.join(skillsRoot, "other-skill", "SKILL.md"));
});
test("uninstall-agent-support deletes AGENTS.md when only SideNote2-managed content exists", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-agent-uninstall-managed-only-"));
const vaultRoot = path.join(tempDir, "vault");
const skillsRoot = path.join(tempDir, "skills");
const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const agentsPath = path.join(vaultRoot, "AGENTS.md");
await mkdir(vaultRoot, { recursive: true });
await mkdir(skillsRoot, { recursive: true });
await writeFile(agentsPath, buildVaultAgentsFileContent({
vaultName: "public",
vaultRootPath: vaultRoot,
pluginVersion: "2.0.2",
}), "utf8");
const { stdout } = await execFile("node", [
cliPath,
"uninstall-agent-support",
"--vault-root",
vaultRoot,
"--skills-root",
skillsRoot,
], {
cwd: process.cwd(),
});
assert.match(stdout, new RegExp(`Removed SideNote2 AGENTS\\.md content and deleted ${agentsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.`));
assert.match(stdout, /No bundled SideNote2 skills found/);
await assert.rejects(access(agentsPath));
});

View file

@ -12,7 +12,7 @@ test("install-skill copies all bundled repo skills into the target Codex skills
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-skill-install-"));
const skillsRoot = path.join(tempDir, "skills");
const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const sourceCommentSkillDir = path.resolve(process.cwd(), "skills/side-note2-note-comments");
const sourceSidenoteSkillDir = path.resolve(process.cwd(), "skills/sidenote2");
const sourceCanvasSkillDir = path.resolve(process.cwd(), "skills/canvas-design");
const { stdout } = await execFile("node", [
@ -24,14 +24,14 @@ test("install-skill copies all bundled repo skills into the target Codex skills
cwd: process.cwd(),
});
assert.match(stdout, /Installed skill side-note2-note-comments/);
assert.match(stdout, /Installed skill sidenote2/);
assert.match(stdout, /Installed skill canvas-design/);
assert.match(stdout, /Restart Codex to pick up new skills/);
const installedCommentSkillDir = path.join(skillsRoot, "side-note2-note-comments");
const installedCommentSkill = await readFile(path.join(installedCommentSkillDir, "SKILL.md"), "utf8");
const sourceCommentSkill = await readFile(path.join(sourceCommentSkillDir, "SKILL.md"), "utf8");
assert.equal(installedCommentSkill, sourceCommentSkill);
const installedSidenoteSkillDir = path.join(skillsRoot, "sidenote2");
const installedSidenoteSkill = await readFile(path.join(installedSidenoteSkillDir, "SKILL.md"), "utf8");
const sourceSidenoteSkill = await readFile(path.join(sourceSidenoteSkillDir, "SKILL.md"), "utf8");
assert.equal(installedSidenoteSkill, sourceSidenoteSkill);
const installedCanvasSkillDir = path.join(skillsRoot, "canvas-design");
const installedCanvasSkill = await readFile(path.join(installedCanvasSkillDir, "SKILL.md"), "utf8");
@ -39,16 +39,16 @@ test("install-skill copies all bundled repo skills into the target Codex skills
assert.equal(installedCanvasSkill, sourceCanvasSkill);
await assert.rejects(access(path.join(installedCanvasSkillDir, "canvas-fonts")));
const installedDirStat = await lstat(installedCommentSkillDir);
const installedDirStat = await lstat(installedSidenoteSkillDir);
assert.equal(installedDirStat.isSymbolicLink(), false);
});
test("install-skill replaces an existing installed skill directory", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-skill-install-overwrite-"));
const skillsRoot = path.join(tempDir, "skills");
const installedSkillDir = path.join(skillsRoot, "side-note2-note-comments");
const installedSkillDir = path.join(skillsRoot, "sidenote2");
const cliPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const sourceSkillPath = path.resolve(process.cwd(), "skills/side-note2-note-comments/SKILL.md");
const sourceSkillPath = path.resolve(process.cwd(), "skills/sidenote2/SKILL.md");
await mkdir(installedSkillDir, { recursive: true });
await writeFile(path.join(installedSkillDir, "SKILL.md"), "stale", "utf8");
@ -57,7 +57,7 @@ test("install-skill replaces an existing installed skill directory", async () =>
cliPath,
"install-skill",
"--name",
"side-note2-note-comments",
"sidenote2",
"--dest",
skillsRoot,
], {
@ -78,18 +78,18 @@ test("install-skill can install a named bundled skill", async () => {
cliPath,
"install-skill",
"--name",
"canvas-design",
"sidenote2",
"--dest",
skillsRoot,
], {
cwd: process.cwd(),
});
assert.match(stdout, /Installed skill canvas-design/);
assert.match(stdout, /Installed skill sidenote2/);
const installedCanvasSkill = await readFile(path.join(skillsRoot, "canvas-design", "SKILL.md"), "utf8");
const sourceCanvasSkill = await readFile(path.resolve(process.cwd(), "skills/canvas-design/SKILL.md"), "utf8");
assert.equal(installedCanvasSkill, sourceCanvasSkill);
const installedSidenoteSkill = await readFile(path.join(skillsRoot, "sidenote2", "SKILL.md"), "utf8");
const sourceSidenoteSkill = await readFile(path.resolve(process.cwd(), "skills/sidenote2/SKILL.md"), "utf8");
assert.equal(installedSidenoteSkill, sourceSidenoteSkill);
await assert.rejects(access(path.join(skillsRoot, "side-note2-note-comments", "SKILL.md")));
await assert.rejects(access(path.join(skillsRoot, "canvas-design", "SKILL.md")));
});

View file

@ -1,6 +1,6 @@
import * as assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
@ -10,6 +10,22 @@ import type { Comment } from "../src/commentManager";
const execFile = promisify(execFileCallback);
async function writeObsidianVaultConfig(homeDir: string, vaultRoot: string): Promise<void> {
const configPath = path.join(homeDir, ".config", "obsidian", "obsidian.json");
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify({
vaults: {
"vault-1": {
path: vaultRoot,
},
},
}, null, 2), "utf8");
}
function buildCommentLocationUri(vaultName: string, filePath: string, commentId: string): string {
return `obsidian://side-note2-comment?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}&commentId=${encodeURIComponent(commentId)}`;
}
function createComment(overrides: Partial<Comment> = {}): Comment {
return {
id: "comment-1",
@ -58,3 +74,42 @@ test("update-note-comment script replaces the targeted comment body", async () =
assert.equal(parsed.comments[0].comment, "Updated body\nSecond line");
assert.equal(parsed.mainContent, "# Title\n\nBody text.");
});
test("comment:update can target a stored comment by obsidian side-note URI", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "sidenote2-comment-uri-script-"));
const homeDir = path.join(tempDir, "home");
const vaultRoot = path.join(tempDir, "Public Vault");
const notePath = path.join(vaultRoot, "Folder", "Note.md");
const commentPath = path.join(tempDir, "comment.md");
const scriptPath = path.resolve(process.cwd(), "bin/sidenote2.mjs");
const noteFilePath = "Folder/Note.md";
const original = serializeNoteComments("# Title\n\nBody text.\n", [createComment({
filePath: noteFilePath,
})]);
await mkdir(path.dirname(notePath), { recursive: true });
await writeObsidianVaultConfig(homeDir, vaultRoot);
await writeFile(notePath, original, "utf8");
await writeFile(commentPath, "Updated from URI\nSecond line\n", "utf8");
const { stdout } = await execFile("node", [
scriptPath,
"comment:update",
"--uri",
buildCommentLocationUri("Public Vault", noteFilePath, "comment-1"),
"--comment-file",
commentPath,
], {
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
},
});
assert.match(stdout, /Updated comment comment-1/);
const updated = await readFile(notePath, "utf8");
const parsed = parseNoteComments(updated, notePath);
assert.equal(parsed.comments[0].comment, "Updated from URI\nSecond line");
});

View file

@ -0,0 +1,249 @@
import * as assert from "node:assert/strict";
import test from "node:test";
import {
VaultAgentsFileController,
buildVaultAgentsFileContent,
planVaultAgentsFileRemoval,
planVaultAgentsFileSync,
} from "../src/control/vaultAgentsFileController";
const baseContext = {
vaultName: "public",
vaultRootPath: "/home/bun/Documents/public",
pluginVersion: "2.0.2",
} as const;
test("buildVaultAgentsFileContent includes managed markers plus the active vault name and path", () => {
const content = buildVaultAgentsFileContent(baseContext);
assert.match(content, /<!-- SideNote2 managed AGENTS start version="2\.0\.2" -->/);
assert.match(content, /This Obsidian vault is `public`\./);
assert.match(content, /Vault root path: `\/home\/bun\/Documents\/public`\./);
assert.match(content, /obsidian:\/\/side-note2-comment\?\.\.\./);
assert.match(content, /append to the existing thread/);
assert.match(content, /mark the targeted thread resolved/);
assert.match(content, /replace the targeted stored comment body/);
assert.match(content, /<!-- SideNote2 managed AGENTS end -->/);
});
test("planVaultAgentsFileSync creates a managed file when AGENTS.md is missing", () => {
const plan = planVaultAgentsFileSync(null, baseContext, "startup");
assert.deepEqual(plan, {
kind: "write",
nextContent: buildVaultAgentsFileContent(baseContext),
reason: "created",
});
});
test("planVaultAgentsFileSync updates an existing managed block in place", () => {
const previousContext = {
...baseContext,
pluginVersion: "2.0.1",
};
const existingContent = [
"# User Vault Rules",
"",
"Keep answers short.",
"",
buildVaultAgentsFileContent(previousContext).trimEnd(),
"",
"## Extra",
"",
"- Never touch archives.",
"",
].join("\n");
const plan = planVaultAgentsFileSync(existingContent, baseContext, "startup");
if (plan.kind !== "write") {
throw new Error("expected a write plan");
}
assert.equal(plan.reason, "updated");
assert.match(plan.nextContent, /# User Vault Rules/);
assert.match(plan.nextContent, /version="2\.0\.2"/);
assert.doesNotMatch(plan.nextContent, /version="2\.0\.1"/);
assert.match(plan.nextContent, /## Extra/);
});
test("planVaultAgentsFileSync inserts a managed block into an unmanaged AGENTS.md on startup", () => {
const plan = planVaultAgentsFileSync("# User AGENTS\n\nDo not modify this file.\n", baseContext, "startup");
if (plan.kind !== "write") {
throw new Error("expected a write plan");
}
assert.equal(plan.reason, "inserted");
assert.match(plan.nextContent, /^# User AGENTS/m);
assert.match(plan.nextContent, /Do not modify this file\./);
assert.match(plan.nextContent, /<!-- SideNote2 managed AGENTS start version="2\.0\.2" -->/);
});
test("planVaultAgentsFileSync inserts a managed block into an unmanaged AGENTS.md for manual install", () => {
const plan = planVaultAgentsFileSync("# User AGENTS\n\nDo not modify this file.\n", baseContext, "manual");
if (plan.kind !== "write") {
throw new Error("expected a write plan");
}
assert.equal(plan.reason, "inserted");
assert.match(plan.nextContent, /^# User AGENTS/m);
assert.match(plan.nextContent, /Do not modify this file\./);
assert.match(plan.nextContent, /<!-- SideNote2 managed AGENTS start version="2\.0\.2" -->/);
});
test("planVaultAgentsFileRemoval removes a managed block but preserves unrelated AGENTS content", () => {
const existingContent = `# User Rules\n\nKeep answers brief.\n\n${buildVaultAgentsFileContent(baseContext)}`;
const plan = planVaultAgentsFileRemoval(existingContent);
assert.equal(plan.kind, "write");
if (plan.kind !== "write") {
throw new Error("expected a write plan");
}
assert.match(plan.nextContent, /# User Rules/);
assert.match(plan.nextContent, /Keep answers brief\./);
assert.doesNotMatch(plan.nextContent, /SideNote2 Vault Agent Routing/);
});
test("planVaultAgentsFileRemoval deletes AGENTS.md when only SideNote2-managed content exists", () => {
const plan = planVaultAgentsFileRemoval(buildVaultAgentsFileContent(baseContext));
assert.deepEqual(plan, { kind: "delete" });
});
test("vault agents file controller installs AGENTS.md into the vault root and reports the resolved path", async () => {
let writtenPath = "";
let writtenContent = "";
let notice = "";
const controller = new VaultAgentsFileController({
getVaultAgentsFileContext: () => baseContext,
vaultRootFileExists: async () => false,
readVaultRootFile: async () => {
throw new Error("should not read missing AGENTS.md");
},
writeVaultRootFile: async (relativePath, content) => {
writtenPath = relativePath;
writtenContent = content;
},
deleteVaultRootFile: async () => {
throw new Error("should not delete on install");
},
showNotice: (message) => {
notice = message;
},
warn: () => {},
});
await controller.installVaultAgentsFile();
assert.equal(writtenPath, "AGENTS.md");
assert.equal(writtenContent, buildVaultAgentsFileContent(baseContext));
assert.equal(notice, "Installed AGENTS.md in /home/bun/Documents/public/AGENTS.md");
});
test("vault agents file controller appends a managed block into an existing unmanaged AGENTS.md on manual install", async () => {
let writtenContent = "";
let notice = "";
const controller = new VaultAgentsFileController({
getVaultAgentsFileContext: () => baseContext,
vaultRootFileExists: async () => true,
readVaultRootFile: async () => "# User AGENTS\n\nDo not modify this file.\n",
writeVaultRootFile: async (_relativePath, content) => {
writtenContent = content;
},
deleteVaultRootFile: async () => {
throw new Error("should not delete on insert");
},
showNotice: (message) => {
notice = message;
},
warn: () => {},
});
await controller.installVaultAgentsFile();
assert.match(writtenContent, /# User AGENTS/);
assert.match(writtenContent, /<!-- SideNote2 managed AGENTS start version="2\.0\.2" -->/);
assert.equal(notice, "Inserted SideNote2 AGENTS instructions into existing /home/bun/Documents/public/AGENTS.md");
});
test("vault agents file controller inserts a managed block into an unmanaged AGENTS.md during startup sync", async () => {
let writtenContent = "";
let noticeCount = 0;
const controller = new VaultAgentsFileController({
getVaultAgentsFileContext: () => baseContext,
vaultRootFileExists: async () => true,
readVaultRootFile: async () => "# User AGENTS\n\nDo not modify this file.\n",
writeVaultRootFile: async (_relativePath, content) => {
writtenContent = content;
},
deleteVaultRootFile: async () => {
throw new Error("should not delete when content remains");
},
showNotice: () => {
noticeCount += 1;
},
warn: () => {},
});
await controller.syncVaultAgentsFileOnStartup();
assert.match(writtenContent, /# User AGENTS/);
assert.match(writtenContent, /<!-- SideNote2 managed AGENTS start version="2\.0\.2" -->/);
assert.equal(noticeCount, 0);
});
test("vault agents file controller removes the SideNote2-managed block and keeps unrelated AGENTS content", async () => {
let writtenContent = "";
let notice = "";
const controller = new VaultAgentsFileController({
getVaultAgentsFileContext: () => baseContext,
vaultRootFileExists: async () => true,
readVaultRootFile: async () => `# User Rules\n\nKeep answers brief.\n\n${buildVaultAgentsFileContent(baseContext)}`,
writeVaultRootFile: async (_relativePath, content) => {
writtenContent = content;
},
deleteVaultRootFile: async () => {
throw new Error("should not delete when unrelated content remains");
},
showNotice: (message) => {
notice = message;
},
warn: () => {},
});
await controller.uninstallVaultAgentsFile();
assert.match(writtenContent, /# User Rules/);
assert.match(writtenContent, /Keep answers brief\./);
assert.doesNotMatch(writtenContent, /SideNote2 Vault Agent Routing/);
assert.equal(notice, "Removed SideNote2 AGENTS instructions from /home/bun/Documents/public/AGENTS.md");
});
test("vault agents file controller deletes AGENTS.md when only SideNote2-managed content exists during uninstall", async () => {
let deletedPath = "";
let notice = "";
const controller = new VaultAgentsFileController({
getVaultAgentsFileContext: () => baseContext,
vaultRootFileExists: async () => true,
readVaultRootFile: async () => buildVaultAgentsFileContent(baseContext),
writeVaultRootFile: async () => {
throw new Error("should not write when delete is enough");
},
deleteVaultRootFile: async (relativePath) => {
deletedPath = relativePath;
},
showNotice: (message) => {
notice = message;
},
warn: () => {},
});
await controller.uninstallVaultAgentsFile();
assert.equal(deletedPath, "AGENTS.md");
assert.equal(notice, "Removed SideNote2 AGENTS.md from /home/bun/Documents/public/AGENTS.md");
});