mirror of
https://github.com/vicky469/aside.git
synced 2026-07-22 17:42:04 +00:00
Add automatic SideNote2 agent routing support
This commit is contained in:
parent
d0c9109754
commit
2643940fb3
21 changed files with 1736 additions and 185 deletions
45
AGENTS.md
Normal file
45
AGENTS.md
Normal 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.
|
||||
26
README.md
26
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
5
scripts/resolve-note-comment.mjs
Normal file
5
scripts/resolve-note-comment.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "../bin/sidenote2.mjs";
|
||||
|
||||
await main(["comment:resolve", ...process.argv.slice(2)]);
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 user’s 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
95
skills/sidenote2/SKILL.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
339
src/control/vaultAgentsFileController.ts
Normal file
339
src/control/vaultAgentsFileController.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
34
src/main.ts
34
src/main.ts
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
106
tests/resolveNoteCommentScript.test.ts
Normal file
106
tests/resolveNoteCommentScript.test.ts
Normal 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);
|
||||
});
|
||||
91
tests/sidenote2AgentSupportCli.test.ts
Normal file
91
tests/sidenote2AgentSupportCli.test.ts
Normal 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));
|
||||
});
|
||||
|
|
@ -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")));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
249
tests/vaultAgentsFileController.test.ts
Normal file
249
tests/vaultAgentsFileController.test.ts
Normal 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");
|
||||
});
|
||||
Loading…
Reference in a new issue