@velvetmonkey/flywheel-crank 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +469 -47
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -141,22 +141,106 @@ function findSection(content, sectionName) {
|
|
|
141
141
|
contentStartLine
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
|
+
function isInsideCodeBlock(lines, currentIndex) {
|
|
145
|
+
let fenceCount = 0;
|
|
146
|
+
for (let i = 0; i < currentIndex; i++) {
|
|
147
|
+
if (lines[i].trim().startsWith("```")) {
|
|
148
|
+
fenceCount++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return fenceCount % 2 === 1;
|
|
152
|
+
}
|
|
153
|
+
function isStructuredLine(line) {
|
|
154
|
+
const trimmed = line.trimStart();
|
|
155
|
+
return trimmed.startsWith("|") || // Table row
|
|
156
|
+
trimmed.startsWith(">") || // Blockquote
|
|
157
|
+
trimmed.startsWith("```") || // Code fence
|
|
158
|
+
/^-{3,}$/.test(trimmed) || // Horizontal rule (dashes)
|
|
159
|
+
/^\*{3,}$/.test(trimmed) || // Horizontal rule (asterisks)
|
|
160
|
+
/^_{3,}$/.test(trimmed);
|
|
161
|
+
}
|
|
162
|
+
function isPreformattedList(content) {
|
|
163
|
+
const trimmed = content.trim();
|
|
164
|
+
if (!trimmed)
|
|
165
|
+
return false;
|
|
166
|
+
const firstLine = trimmed.split("\n")[0];
|
|
167
|
+
return /^[-*+]\s/.test(firstLine) || // Bullet
|
|
168
|
+
/^\d+\.\s/.test(firstLine) || // Numbered
|
|
169
|
+
/^[-*+]\s*\[[ xX]\]/.test(firstLine);
|
|
170
|
+
}
|
|
144
171
|
function formatContent(content, format) {
|
|
145
172
|
const trimmed = content.trim();
|
|
146
173
|
switch (format) {
|
|
147
174
|
case "plain":
|
|
148
175
|
return trimmed;
|
|
149
|
-
case "bullet":
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
176
|
+
case "bullet": {
|
|
177
|
+
if (isPreformattedList(trimmed)) {
|
|
178
|
+
return trimmed;
|
|
179
|
+
}
|
|
180
|
+
const lines = trimmed.split("\n");
|
|
181
|
+
return lines.map((line, i) => {
|
|
182
|
+
if (i === 0)
|
|
183
|
+
return `- ${line}`;
|
|
184
|
+
if (line === "")
|
|
185
|
+
return "";
|
|
186
|
+
if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
|
|
187
|
+
return line;
|
|
188
|
+
}
|
|
189
|
+
return ` ${line}`;
|
|
190
|
+
}).join("\n");
|
|
191
|
+
}
|
|
192
|
+
case "task": {
|
|
193
|
+
if (isPreformattedList(trimmed)) {
|
|
194
|
+
return trimmed;
|
|
195
|
+
}
|
|
196
|
+
const lines = trimmed.split("\n");
|
|
197
|
+
return lines.map((line, i) => {
|
|
198
|
+
if (i === 0)
|
|
199
|
+
return `- [ ] ${line}`;
|
|
200
|
+
if (line === "")
|
|
201
|
+
return "";
|
|
202
|
+
if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
|
|
203
|
+
return line;
|
|
204
|
+
}
|
|
205
|
+
return ` ${line}`;
|
|
206
|
+
}).join("\n");
|
|
207
|
+
}
|
|
208
|
+
case "numbered": {
|
|
209
|
+
if (isPreformattedList(trimmed)) {
|
|
210
|
+
return trimmed;
|
|
211
|
+
}
|
|
212
|
+
const lines = trimmed.split("\n");
|
|
213
|
+
return lines.map((line, i) => {
|
|
214
|
+
if (i === 0)
|
|
215
|
+
return `1. ${line}`;
|
|
216
|
+
if (line === "")
|
|
217
|
+
return "";
|
|
218
|
+
if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
|
|
219
|
+
return line;
|
|
220
|
+
}
|
|
221
|
+
return ` ${line}`;
|
|
222
|
+
}).join("\n");
|
|
223
|
+
}
|
|
155
224
|
case "timestamp-bullet": {
|
|
225
|
+
if (isPreformattedList(trimmed)) {
|
|
226
|
+
return trimmed;
|
|
227
|
+
}
|
|
156
228
|
const now = /* @__PURE__ */ new Date();
|
|
157
229
|
const hours = String(now.getHours()).padStart(2, "0");
|
|
158
230
|
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
159
|
-
|
|
231
|
+
const prefix = `- **${hours}:${minutes}** `;
|
|
232
|
+
const lines = trimmed.split("\n");
|
|
233
|
+
const indent = " ";
|
|
234
|
+
return lines.map((line, i) => {
|
|
235
|
+
if (i === 0)
|
|
236
|
+
return `${prefix}${line}`;
|
|
237
|
+
if (line === "")
|
|
238
|
+
return "";
|
|
239
|
+
if (isInsideCodeBlock(lines, i) || isStructuredLine(line)) {
|
|
240
|
+
return line;
|
|
241
|
+
}
|
|
242
|
+
return `${indent}${line}`;
|
|
243
|
+
}).join("\n");
|
|
160
244
|
}
|
|
161
245
|
default:
|
|
162
246
|
return trimmed;
|
|
@@ -198,7 +282,13 @@ function insertInSection(content, section, newContent, position, options) {
|
|
|
198
282
|
break;
|
|
199
283
|
}
|
|
200
284
|
if (indent) {
|
|
201
|
-
const
|
|
285
|
+
const contentLines = formattedContent.split("\n");
|
|
286
|
+
const indentedContent = contentLines.map((line, i) => {
|
|
287
|
+
if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
|
|
288
|
+
return line;
|
|
289
|
+
}
|
|
290
|
+
return indent + line;
|
|
291
|
+
}).join("\n");
|
|
202
292
|
lines.splice(section.contentStartLine, 0, indentedContent);
|
|
203
293
|
} else {
|
|
204
294
|
lines.splice(section.contentStartLine, 0, formattedContent);
|
|
@@ -217,7 +307,13 @@ function insertInSection(content, section, newContent, position, options) {
|
|
|
217
307
|
if (lastContentLineIdx >= section.contentStartLine && isEmptyPlaceholder(lines[lastContentLineIdx])) {
|
|
218
308
|
if (options?.preserveListNesting) {
|
|
219
309
|
const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
|
|
220
|
-
const
|
|
310
|
+
const contentLines = formattedContent.split("\n");
|
|
311
|
+
const indentedContent = contentLines.map((line, i) => {
|
|
312
|
+
if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
|
|
313
|
+
return line;
|
|
314
|
+
}
|
|
315
|
+
return indent + line;
|
|
316
|
+
}).join("\n");
|
|
221
317
|
lines[lastContentLineIdx] = indentedContent;
|
|
222
318
|
} else {
|
|
223
319
|
lines[lastContentLineIdx] = formattedContent;
|
|
@@ -236,7 +332,13 @@ function insertInSection(content, section, newContent, position, options) {
|
|
|
236
332
|
}
|
|
237
333
|
if (options?.preserveListNesting) {
|
|
238
334
|
const indent = detectSectionBaseIndentation(lines, section.contentStartLine, section.endLine);
|
|
239
|
-
const
|
|
335
|
+
const contentLines = formattedContent.split("\n");
|
|
336
|
+
const indentedContent = contentLines.map((line, i) => {
|
|
337
|
+
if (line === "" || isInsideCodeBlock(contentLines, i) || isStructuredLine(line)) {
|
|
338
|
+
return line;
|
|
339
|
+
}
|
|
340
|
+
return indent + line;
|
|
341
|
+
}).join("\n");
|
|
240
342
|
lines.splice(insertLine, 0, indentedContent);
|
|
241
343
|
} else {
|
|
242
344
|
lines.splice(insertLine, 0, formattedContent);
|
|
@@ -421,9 +523,236 @@ function replaceInSection(content, section, search, replacement, mode = "first",
|
|
|
421
523
|
};
|
|
422
524
|
}
|
|
423
525
|
|
|
526
|
+
// src/core/validator.ts
|
|
527
|
+
var TIMESTAMP_PATTERN = /^\*\*\d{2}:\d{2}\*\*/;
|
|
528
|
+
var NON_MARKDOWN_BULLET_PATTERNS = [
|
|
529
|
+
/^[\s]*\u2022/,
|
|
530
|
+
// • (bullet)
|
|
531
|
+
/^[\s]*\u25E6/,
|
|
532
|
+
// ◦ (hollow bullet)
|
|
533
|
+
/^[\s]*\u25AA/,
|
|
534
|
+
// ▪ (small square)
|
|
535
|
+
/^[\s]*\u25AB/,
|
|
536
|
+
// ▫ (hollow small square)
|
|
537
|
+
/^[\s]*\u2023/,
|
|
538
|
+
// ‣ (triangular bullet)
|
|
539
|
+
/^[\s]*\u2043/
|
|
540
|
+
// ⁃ (hyphen bullet)
|
|
541
|
+
];
|
|
542
|
+
var EMBEDDED_HEADING_PATTERN = /^#{1,6}\s+/m;
|
|
543
|
+
function validateInput(content, format) {
|
|
544
|
+
const warnings = [];
|
|
545
|
+
if (format === "timestamp-bullet" && TIMESTAMP_PATTERN.test(content.trim())) {
|
|
546
|
+
warnings.push({
|
|
547
|
+
type: "double-timestamp",
|
|
548
|
+
message: "Content already contains a timestamp prefix, and format is timestamp-bullet",
|
|
549
|
+
suggestion: 'Use format: "plain" or "bullet" instead to avoid duplicate timestamps'
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const lines = content.split("\n");
|
|
553
|
+
for (const line of lines) {
|
|
554
|
+
for (const pattern of NON_MARKDOWN_BULLET_PATTERNS) {
|
|
555
|
+
if (pattern.test(line)) {
|
|
556
|
+
warnings.push({
|
|
557
|
+
type: "non-markdown-bullets",
|
|
558
|
+
message: `Non-markdown bullet character detected: "${line.trim().charAt(0)}"`,
|
|
559
|
+
suggestion: "Use markdown bullets (-) for proper rendering"
|
|
560
|
+
});
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (EMBEDDED_HEADING_PATTERN.test(content)) {
|
|
566
|
+
warnings.push({
|
|
567
|
+
type: "embedded-heading",
|
|
568
|
+
message: "Content contains markdown heading syntax (##)",
|
|
569
|
+
suggestion: "Use bold (**text**) instead of headings inside list items"
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const fenceCount = (content.match(/^```/gm) || []).length;
|
|
573
|
+
if (fenceCount % 2 !== 0) {
|
|
574
|
+
warnings.push({
|
|
575
|
+
type: "orphaned-fence",
|
|
576
|
+
message: "Odd number of code fence markers (```) detected",
|
|
577
|
+
suggestion: "Ensure code blocks are properly closed"
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
isValid: warnings.length === 0,
|
|
582
|
+
warnings
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function normalizeInput(content, format) {
|
|
586
|
+
let normalized = content;
|
|
587
|
+
const changes = [];
|
|
588
|
+
if (format === "timestamp-bullet" && TIMESTAMP_PATTERN.test(normalized.trim())) {
|
|
589
|
+
normalized = normalized.trim().replace(TIMESTAMP_PATTERN, "").trim();
|
|
590
|
+
changes.push("Removed duplicate timestamp prefix");
|
|
591
|
+
}
|
|
592
|
+
const lines = normalized.split("\n");
|
|
593
|
+
let bulletReplaced = false;
|
|
594
|
+
const normalizedLines = lines.map((line) => {
|
|
595
|
+
for (const pattern of NON_MARKDOWN_BULLET_PATTERNS) {
|
|
596
|
+
if (pattern.test(line)) {
|
|
597
|
+
bulletReplaced = true;
|
|
598
|
+
return line.replace(/^([\s]*)[•◦▪▫‣⁃]\s*/, "$1- ");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return line;
|
|
602
|
+
});
|
|
603
|
+
if (bulletReplaced) {
|
|
604
|
+
normalized = normalizedLines.join("\n");
|
|
605
|
+
changes.push('Replaced non-markdown bullets with "-"');
|
|
606
|
+
}
|
|
607
|
+
const trimmed = normalized.replace(/\n{3,}/g, "\n\n");
|
|
608
|
+
if (trimmed !== normalized) {
|
|
609
|
+
normalized = trimmed;
|
|
610
|
+
changes.push("Trimmed excessive blank lines");
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
content: normalized,
|
|
614
|
+
normalized: changes.length > 0,
|
|
615
|
+
changes
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function validateOutput(formatted) {
|
|
619
|
+
const issues = [];
|
|
620
|
+
const tableRows = formatted.match(/^\s*\|.*\|/gm);
|
|
621
|
+
if (tableRows && tableRows.length > 1) {
|
|
622
|
+
const pipeCounts = tableRows.map((row) => (row.match(/\|/g) || []).length);
|
|
623
|
+
const firstPipeCount = pipeCounts[0];
|
|
624
|
+
const hasInconsistentPipes = pipeCounts.some((count) => count !== firstPipeCount);
|
|
625
|
+
if (hasInconsistentPipes) {
|
|
626
|
+
issues.push({
|
|
627
|
+
type: "broken-table",
|
|
628
|
+
severity: "error",
|
|
629
|
+
message: "Table rows have inconsistent pipe counts - table alignment may be broken"
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const fenceCount = (formatted.match(/^```/gm) || []).length;
|
|
634
|
+
if (fenceCount % 2 !== 0) {
|
|
635
|
+
issues.push({
|
|
636
|
+
type: "orphaned-fence",
|
|
637
|
+
severity: "error",
|
|
638
|
+
message: "Odd number of code fence markers - code block may be unclosed"
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const indentedFences = formatted.match(/^[ \t]+```/gm);
|
|
642
|
+
if (indentedFences && indentedFences.length > 0) {
|
|
643
|
+
const lines = formatted.split("\n");
|
|
644
|
+
for (let i = 0; i < lines.length; i++) {
|
|
645
|
+
if (/^[ \t]+```/.test(lines[i])) {
|
|
646
|
+
issues.push({
|
|
647
|
+
type: "indented-fence",
|
|
648
|
+
severity: "warning",
|
|
649
|
+
message: "Code fence marker is indented - this may break the code block",
|
|
650
|
+
line: i + 1
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const blockquoteLines = formatted.match(/^[ \t]*>/gm);
|
|
656
|
+
if (blockquoteLines) {
|
|
657
|
+
const lines = formatted.split("\n");
|
|
658
|
+
let inBlockquote = false;
|
|
659
|
+
for (let i = 0; i < lines.length; i++) {
|
|
660
|
+
const line = lines[i];
|
|
661
|
+
const isBlockquoteLine = /^[ \t]*>/.test(line);
|
|
662
|
+
if (isBlockquoteLine) {
|
|
663
|
+
inBlockquote = true;
|
|
664
|
+
} else if (inBlockquote && line.trim() !== "" && !/^[ \t]*>/.test(line)) {
|
|
665
|
+
if (/^[ \t]+[^-*>\d]/.test(line)) {
|
|
666
|
+
issues.push({
|
|
667
|
+
type: "broken-blockquote",
|
|
668
|
+
severity: "warning",
|
|
669
|
+
message: "Blockquote structure may be broken - continuation line not prefixed with >",
|
|
670
|
+
line: i + 1
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
inBlockquote = false;
|
|
674
|
+
} else if (line.trim() === "") {
|
|
675
|
+
inBlockquote = false;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
valid: issues.filter((i) => i.severity === "error").length === 0,
|
|
681
|
+
issues
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
function runValidationPipeline(content, format, options = {}) {
|
|
685
|
+
const {
|
|
686
|
+
validate = true,
|
|
687
|
+
normalize = true,
|
|
688
|
+
guardrails = "warn"
|
|
689
|
+
} = options;
|
|
690
|
+
let processedContent = content;
|
|
691
|
+
let inputWarnings = [];
|
|
692
|
+
let normalizationChanges = [];
|
|
693
|
+
let outputIssues = [];
|
|
694
|
+
let blocked = false;
|
|
695
|
+
let blockReason;
|
|
696
|
+
if (validate) {
|
|
697
|
+
const inputResult = validateInput(content, format);
|
|
698
|
+
inputWarnings = inputResult.warnings;
|
|
699
|
+
}
|
|
700
|
+
if (normalize) {
|
|
701
|
+
const normResult = normalizeInput(processedContent, format);
|
|
702
|
+
processedContent = normResult.content;
|
|
703
|
+
normalizationChanges = normResult.changes;
|
|
704
|
+
}
|
|
705
|
+
if (guardrails !== "off") {
|
|
706
|
+
const outputResult = validateOutput(processedContent);
|
|
707
|
+
outputIssues = outputResult.issues;
|
|
708
|
+
if (guardrails === "strict" && !outputResult.valid) {
|
|
709
|
+
blocked = true;
|
|
710
|
+
const errors = outputIssues.filter((i) => i.severity === "error");
|
|
711
|
+
blockReason = `Output validation failed: ${errors.map((e) => e.message).join("; ")}`;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
content: processedContent,
|
|
716
|
+
inputWarnings,
|
|
717
|
+
outputIssues,
|
|
718
|
+
normalizationChanges,
|
|
719
|
+
blocked,
|
|
720
|
+
blockReason
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
424
724
|
// src/core/git.ts
|
|
425
725
|
import { simpleGit, CheckRepoActions } from "simple-git";
|
|
426
726
|
import path2 from "path";
|
|
727
|
+
import fs2 from "fs/promises";
|
|
728
|
+
var LAST_COMMIT_FILE = ".claude/last-crank-commit.json";
|
|
729
|
+
async function saveLastCrankCommit(vaultPath2, hash, message) {
|
|
730
|
+
const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
|
|
731
|
+
const dirPath = path2.dirname(filePath);
|
|
732
|
+
await fs2.mkdir(dirPath, { recursive: true });
|
|
733
|
+
const data = {
|
|
734
|
+
hash,
|
|
735
|
+
message,
|
|
736
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
737
|
+
};
|
|
738
|
+
await fs2.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
739
|
+
}
|
|
740
|
+
async function getLastCrankCommit(vaultPath2) {
|
|
741
|
+
try {
|
|
742
|
+
const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
|
|
743
|
+
const content = await fs2.readFile(filePath, "utf-8");
|
|
744
|
+
return JSON.parse(content);
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function clearLastCrankCommit(vaultPath2) {
|
|
750
|
+
try {
|
|
751
|
+
const filePath = path2.join(vaultPath2, LAST_COMMIT_FILE);
|
|
752
|
+
await fs2.unlink(filePath);
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
427
756
|
async function isGitRepo(vaultPath2) {
|
|
428
757
|
try {
|
|
429
758
|
const git = simpleGit(vaultPath2);
|
|
@@ -447,6 +776,9 @@ async function commitChange(vaultPath2, filePath, messagePrefix) {
|
|
|
447
776
|
const fileName = path2.basename(filePath);
|
|
448
777
|
const commitMessage = `${messagePrefix} Update ${fileName}`;
|
|
449
778
|
const result = await git.commit(commitMessage);
|
|
779
|
+
if (result.commit) {
|
|
780
|
+
await saveLastCrankCommit(vaultPath2, result.commit, commitMessage);
|
|
781
|
+
}
|
|
450
782
|
return {
|
|
451
783
|
success: true,
|
|
452
784
|
hash: result.commit
|
|
@@ -1722,7 +2054,7 @@ function suggestRelatedLinks(content, options = {}) {
|
|
|
1722
2054
|
}
|
|
1723
2055
|
|
|
1724
2056
|
// src/tools/mutations.ts
|
|
1725
|
-
import
|
|
2057
|
+
import fs3 from "fs/promises";
|
|
1726
2058
|
import path5 from "path";
|
|
1727
2059
|
function registerMutationTools(server2, vaultPath2) {
|
|
1728
2060
|
server2.tool(
|
|
@@ -1738,13 +2070,16 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1738
2070
|
skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
|
|
1739
2071
|
preserveListNesting: z.boolean().default(true).describe("Detect and preserve the indentation level of surrounding list items. Set false to disable."),
|
|
1740
2072
|
suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
|
|
1741
|
-
maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
|
|
2073
|
+
maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
|
|
2074
|
+
validate: z.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
|
|
2075
|
+
normalize: z.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
|
|
2076
|
+
guardrails: z.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables')
|
|
1742
2077
|
},
|
|
1743
|
-
async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks, maxSuggestions }) => {
|
|
2078
|
+
async ({ path: notePath, section, content, position, format, commit, skipWikilinks, preserveListNesting, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails }) => {
|
|
1744
2079
|
try {
|
|
1745
2080
|
const fullPath = path5.join(vaultPath2, notePath);
|
|
1746
2081
|
try {
|
|
1747
|
-
await
|
|
2082
|
+
await fs3.access(fullPath);
|
|
1748
2083
|
} catch {
|
|
1749
2084
|
const result2 = {
|
|
1750
2085
|
success: false,
|
|
@@ -1767,7 +2102,25 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1767
2102
|
result2.tokensEstimate = estimateTokens(result2);
|
|
1768
2103
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
1769
2104
|
}
|
|
1770
|
-
|
|
2105
|
+
const validationResult = runValidationPipeline(content, format, {
|
|
2106
|
+
validate,
|
|
2107
|
+
normalize,
|
|
2108
|
+
guardrails
|
|
2109
|
+
});
|
|
2110
|
+
if (validationResult.blocked) {
|
|
2111
|
+
const result2 = {
|
|
2112
|
+
success: false,
|
|
2113
|
+
message: validationResult.blockReason || "Output validation failed",
|
|
2114
|
+
path: notePath,
|
|
2115
|
+
warnings: validationResult.inputWarnings,
|
|
2116
|
+
outputIssues: validationResult.outputIssues,
|
|
2117
|
+
tokensEstimate: 0
|
|
2118
|
+
};
|
|
2119
|
+
result2.tokensEstimate = estimateTokens(result2);
|
|
2120
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2121
|
+
}
|
|
2122
|
+
let workingContent = validationResult.content;
|
|
2123
|
+
let { content: processedContent, wikilinkInfo } = maybeApplyWikilinks(workingContent, skipWikilinks);
|
|
1771
2124
|
let suggestInfo;
|
|
1772
2125
|
if (suggestOutgoingLinks && !skipWikilinks) {
|
|
1773
2126
|
const result2 = suggestRelatedLinks(processedContent, { maxSuggestions });
|
|
@@ -1805,8 +2158,12 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1805
2158
|
preview,
|
|
1806
2159
|
gitCommit,
|
|
1807
2160
|
gitError,
|
|
1808
|
-
tokensEstimate: 0
|
|
2161
|
+
tokensEstimate: 0,
|
|
1809
2162
|
// Will be set below
|
|
2163
|
+
// Include validation info if present
|
|
2164
|
+
...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
|
|
2165
|
+
...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
|
|
2166
|
+
...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
|
|
1810
2167
|
};
|
|
1811
2168
|
result.tokensEstimate = estimateTokens(result);
|
|
1812
2169
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
@@ -1837,7 +2194,7 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1837
2194
|
try {
|
|
1838
2195
|
const fullPath = path5.join(vaultPath2, notePath);
|
|
1839
2196
|
try {
|
|
1840
|
-
await
|
|
2197
|
+
await fs3.access(fullPath);
|
|
1841
2198
|
} catch {
|
|
1842
2199
|
const result2 = {
|
|
1843
2200
|
success: false,
|
|
@@ -1924,13 +2281,16 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1924
2281
|
commit: z.boolean().default(false).describe("If true, commit this change to git (creates undo point)"),
|
|
1925
2282
|
skipWikilinks: z.boolean().default(false).describe("If true, skip auto-wikilink application on replacement text"),
|
|
1926
2283
|
suggestOutgoingLinks: z.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
|
|
1927
|
-
maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)")
|
|
2284
|
+
maxSuggestions: z.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
|
|
2285
|
+
validate: z.boolean().default(true).describe("Check input for common issues (double timestamps, non-markdown bullets, etc.)"),
|
|
2286
|
+
normalize: z.boolean().default(true).describe("Auto-fix common issues before formatting (replace \u2022 with -, trim excessive whitespace, etc.)"),
|
|
2287
|
+
guardrails: z.enum(["warn", "strict", "off"]).default("warn").describe('Output validation mode: "warn" returns issues but proceeds, "strict" blocks on errors, "off" disables')
|
|
1928
2288
|
},
|
|
1929
|
-
async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions }) => {
|
|
2289
|
+
async ({ path: notePath, section, search, replacement, mode, useRegex, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, validate, normalize, guardrails }) => {
|
|
1930
2290
|
try {
|
|
1931
2291
|
const fullPath = path5.join(vaultPath2, notePath);
|
|
1932
2292
|
try {
|
|
1933
|
-
await
|
|
2293
|
+
await fs3.access(fullPath);
|
|
1934
2294
|
} catch {
|
|
1935
2295
|
const result2 = {
|
|
1936
2296
|
success: false,
|
|
@@ -1953,7 +2313,25 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
1953
2313
|
result2.tokensEstimate = estimateTokens(result2);
|
|
1954
2314
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
1955
2315
|
}
|
|
1956
|
-
|
|
2316
|
+
const validationResult = runValidationPipeline(replacement, "plain", {
|
|
2317
|
+
validate,
|
|
2318
|
+
normalize,
|
|
2319
|
+
guardrails
|
|
2320
|
+
});
|
|
2321
|
+
if (validationResult.blocked) {
|
|
2322
|
+
const result2 = {
|
|
2323
|
+
success: false,
|
|
2324
|
+
message: validationResult.blockReason || "Output validation failed",
|
|
2325
|
+
path: notePath,
|
|
2326
|
+
warnings: validationResult.inputWarnings,
|
|
2327
|
+
outputIssues: validationResult.outputIssues,
|
|
2328
|
+
tokensEstimate: 0
|
|
2329
|
+
};
|
|
2330
|
+
result2.tokensEstimate = estimateTokens(result2);
|
|
2331
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2332
|
+
}
|
|
2333
|
+
let workingReplacement = validationResult.content;
|
|
2334
|
+
let { content: processedReplacement, wikilinkInfo } = maybeApplyWikilinks(workingReplacement, skipWikilinks);
|
|
1957
2335
|
let suggestInfo;
|
|
1958
2336
|
if (suggestOutgoingLinks && !skipWikilinks) {
|
|
1959
2337
|
const result2 = suggestRelatedLinks(processedReplacement, { maxSuggestions });
|
|
@@ -2002,7 +2380,11 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
2002
2380
|
preview: previewLines.join("\n"),
|
|
2003
2381
|
gitCommit,
|
|
2004
2382
|
gitError,
|
|
2005
|
-
tokensEstimate: 0
|
|
2383
|
+
tokensEstimate: 0,
|
|
2384
|
+
// Include validation info if present
|
|
2385
|
+
...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
|
|
2386
|
+
...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
|
|
2387
|
+
...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
|
|
2006
2388
|
};
|
|
2007
2389
|
result.tokensEstimate = estimateTokens(result);
|
|
2008
2390
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
@@ -2023,7 +2405,7 @@ function registerMutationTools(server2, vaultPath2) {
|
|
|
2023
2405
|
|
|
2024
2406
|
// src/tools/tasks.ts
|
|
2025
2407
|
import { z as z2 } from "zod";
|
|
2026
|
-
import
|
|
2408
|
+
import fs4 from "fs/promises";
|
|
2027
2409
|
import path6 from "path";
|
|
2028
2410
|
var TASK_REGEX = /^(\s*)-\s*\[([ xX])\]\s*(.*)$/;
|
|
2029
2411
|
function findTasks(content, section) {
|
|
@@ -2082,7 +2464,7 @@ function registerTaskTools(server2, vaultPath2) {
|
|
|
2082
2464
|
try {
|
|
2083
2465
|
const fullPath = path6.join(vaultPath2, notePath);
|
|
2084
2466
|
try {
|
|
2085
|
-
await
|
|
2467
|
+
await fs4.access(fullPath);
|
|
2086
2468
|
} catch {
|
|
2087
2469
|
const result2 = {
|
|
2088
2470
|
success: false,
|
|
@@ -2184,13 +2566,16 @@ function registerTaskTools(server2, vaultPath2) {
|
|
|
2184
2566
|
skipWikilinks: z2.boolean().default(false).describe("If true, skip auto-wikilink application (wikilinks are applied by default)"),
|
|
2185
2567
|
suggestOutgoingLinks: z2.boolean().default(true).describe('Append suggested outgoing wikilinks based on content (e.g., "\u2192 [[AI]] [[Philosophy]]"). Set false to disable.'),
|
|
2186
2568
|
maxSuggestions: z2.number().min(1).max(10).default(3).describe("Maximum number of suggested wikilinks to append (1-10, default: 3)"),
|
|
2187
|
-
preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true")
|
|
2569
|
+
preserveListNesting: z2.boolean().default(true).describe("Preserve indentation when inserting into nested lists. Default: true"),
|
|
2570
|
+
validate: z2.boolean().default(true).describe("Check input for common issues"),
|
|
2571
|
+
normalize: z2.boolean().default(true).describe("Auto-fix common issues before formatting"),
|
|
2572
|
+
guardrails: z2.enum(["warn", "strict", "off"]).default("warn").describe("Output validation mode")
|
|
2188
2573
|
},
|
|
2189
|
-
async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting }) => {
|
|
2574
|
+
async ({ path: notePath, section, task, position, completed, commit, skipWikilinks, suggestOutgoingLinks, maxSuggestions, preserveListNesting, validate, normalize, guardrails }) => {
|
|
2190
2575
|
try {
|
|
2191
2576
|
const fullPath = path6.join(vaultPath2, notePath);
|
|
2192
2577
|
try {
|
|
2193
|
-
await
|
|
2578
|
+
await fs4.access(fullPath);
|
|
2194
2579
|
} catch {
|
|
2195
2580
|
const result2 = {
|
|
2196
2581
|
success: false,
|
|
@@ -2213,7 +2598,25 @@ function registerTaskTools(server2, vaultPath2) {
|
|
|
2213
2598
|
result2.tokensEstimate = estimateTokens(result2);
|
|
2214
2599
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2215
2600
|
}
|
|
2216
|
-
|
|
2601
|
+
const validationResult = runValidationPipeline(task.trim(), "task", {
|
|
2602
|
+
validate,
|
|
2603
|
+
normalize,
|
|
2604
|
+
guardrails
|
|
2605
|
+
});
|
|
2606
|
+
if (validationResult.blocked) {
|
|
2607
|
+
const result2 = {
|
|
2608
|
+
success: false,
|
|
2609
|
+
message: validationResult.blockReason || "Output validation failed",
|
|
2610
|
+
path: notePath,
|
|
2611
|
+
warnings: validationResult.inputWarnings,
|
|
2612
|
+
outputIssues: validationResult.outputIssues,
|
|
2613
|
+
tokensEstimate: 0
|
|
2614
|
+
};
|
|
2615
|
+
result2.tokensEstimate = estimateTokens(result2);
|
|
2616
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2617
|
+
}
|
|
2618
|
+
let workingTask = validationResult.content;
|
|
2619
|
+
let { content: processedTask, wikilinkInfo } = maybeApplyWikilinks(workingTask, skipWikilinks);
|
|
2217
2620
|
let suggestInfo;
|
|
2218
2621
|
if (suggestOutgoingLinks && !skipWikilinks) {
|
|
2219
2622
|
const result2 = suggestRelatedLinks(processedTask, { maxSuggestions });
|
|
@@ -2251,7 +2654,11 @@ function registerTaskTools(server2, vaultPath2) {
|
|
|
2251
2654
|
(${infoLines.join("; ")})` : ""),
|
|
2252
2655
|
gitCommit,
|
|
2253
2656
|
gitError,
|
|
2254
|
-
tokensEstimate: 0
|
|
2657
|
+
tokensEstimate: 0,
|
|
2658
|
+
// Include validation info if present
|
|
2659
|
+
...validationResult.inputWarnings.length > 0 && { warnings: validationResult.inputWarnings },
|
|
2660
|
+
...validationResult.outputIssues.length > 0 && { outputIssues: validationResult.outputIssues },
|
|
2661
|
+
...validationResult.normalizationChanges.length > 0 && { normalizationChanges: validationResult.normalizationChanges }
|
|
2255
2662
|
};
|
|
2256
2663
|
result.tokensEstimate = estimateTokens(result);
|
|
2257
2664
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
@@ -2272,7 +2679,7 @@ function registerTaskTools(server2, vaultPath2) {
|
|
|
2272
2679
|
|
|
2273
2680
|
// src/tools/frontmatter.ts
|
|
2274
2681
|
import { z as z3 } from "zod";
|
|
2275
|
-
import
|
|
2682
|
+
import fs5 from "fs/promises";
|
|
2276
2683
|
import path7 from "path";
|
|
2277
2684
|
function registerFrontmatterTools(server2, vaultPath2) {
|
|
2278
2685
|
server2.tool(
|
|
@@ -2287,7 +2694,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
|
|
|
2287
2694
|
try {
|
|
2288
2695
|
const fullPath = path7.join(vaultPath2, notePath);
|
|
2289
2696
|
try {
|
|
2290
|
-
await
|
|
2697
|
+
await fs5.access(fullPath);
|
|
2291
2698
|
} catch {
|
|
2292
2699
|
const result2 = {
|
|
2293
2700
|
success: false,
|
|
@@ -2343,7 +2750,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
|
|
|
2343
2750
|
try {
|
|
2344
2751
|
const fullPath = path7.join(vaultPath2, notePath);
|
|
2345
2752
|
try {
|
|
2346
|
-
await
|
|
2753
|
+
await fs5.access(fullPath);
|
|
2347
2754
|
} catch {
|
|
2348
2755
|
const result2 = {
|
|
2349
2756
|
success: false,
|
|
@@ -2397,7 +2804,7 @@ function registerFrontmatterTools(server2, vaultPath2) {
|
|
|
2397
2804
|
|
|
2398
2805
|
// src/tools/notes.ts
|
|
2399
2806
|
import { z as z4 } from "zod";
|
|
2400
|
-
import
|
|
2807
|
+
import fs6 from "fs/promises";
|
|
2401
2808
|
import path8 from "path";
|
|
2402
2809
|
function registerNoteTools(server2, vaultPath2) {
|
|
2403
2810
|
server2.tool(
|
|
@@ -2422,7 +2829,7 @@ function registerNoteTools(server2, vaultPath2) {
|
|
|
2422
2829
|
}
|
|
2423
2830
|
const fullPath = path8.join(vaultPath2, notePath);
|
|
2424
2831
|
try {
|
|
2425
|
-
await
|
|
2832
|
+
await fs6.access(fullPath);
|
|
2426
2833
|
if (!overwrite) {
|
|
2427
2834
|
const result2 = {
|
|
2428
2835
|
success: false,
|
|
@@ -2434,7 +2841,7 @@ function registerNoteTools(server2, vaultPath2) {
|
|
|
2434
2841
|
} catch {
|
|
2435
2842
|
}
|
|
2436
2843
|
const dir = path8.dirname(fullPath);
|
|
2437
|
-
await
|
|
2844
|
+
await fs6.mkdir(dir, { recursive: true });
|
|
2438
2845
|
await writeVaultFile(vaultPath2, notePath, content, frontmatter);
|
|
2439
2846
|
let gitCommit;
|
|
2440
2847
|
let gitError;
|
|
@@ -2494,7 +2901,7 @@ Content length: ${content.length} chars`,
|
|
|
2494
2901
|
}
|
|
2495
2902
|
const fullPath = path8.join(vaultPath2, notePath);
|
|
2496
2903
|
try {
|
|
2497
|
-
await
|
|
2904
|
+
await fs6.access(fullPath);
|
|
2498
2905
|
} catch {
|
|
2499
2906
|
const result2 = {
|
|
2500
2907
|
success: false,
|
|
@@ -2503,7 +2910,7 @@ Content length: ${content.length} chars`,
|
|
|
2503
2910
|
};
|
|
2504
2911
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2505
2912
|
}
|
|
2506
|
-
await
|
|
2913
|
+
await fs6.unlink(fullPath);
|
|
2507
2914
|
let gitCommit;
|
|
2508
2915
|
let gitError;
|
|
2509
2916
|
if (commit) {
|
|
@@ -2537,7 +2944,7 @@ Content length: ${content.length} chars`,
|
|
|
2537
2944
|
|
|
2538
2945
|
// src/tools/system.ts
|
|
2539
2946
|
import { z as z5 } from "zod";
|
|
2540
|
-
import
|
|
2947
|
+
import fs7 from "fs/promises";
|
|
2541
2948
|
import path9 from "path";
|
|
2542
2949
|
function registerSystemTools(server2, vaultPath2) {
|
|
2543
2950
|
server2.tool(
|
|
@@ -2560,7 +2967,7 @@ function registerSystemTools(server2, vaultPath2) {
|
|
|
2560
2967
|
}
|
|
2561
2968
|
const fullPath = path9.join(vaultPath2, notePath);
|
|
2562
2969
|
try {
|
|
2563
|
-
await
|
|
2970
|
+
await fs7.access(fullPath);
|
|
2564
2971
|
} catch {
|
|
2565
2972
|
const result2 = {
|
|
2566
2973
|
success: false,
|
|
@@ -2606,8 +3013,8 @@ function registerSystemTools(server2, vaultPath2) {
|
|
|
2606
3013
|
async ({ confirm }) => {
|
|
2607
3014
|
try {
|
|
2608
3015
|
if (!confirm) {
|
|
2609
|
-
const
|
|
2610
|
-
if (!
|
|
3016
|
+
const lastCommit2 = await getLastCommit(vaultPath2);
|
|
3017
|
+
if (!lastCommit2) {
|
|
2611
3018
|
const result3 = {
|
|
2612
3019
|
success: false,
|
|
2613
3020
|
message: "No commits found to undo",
|
|
@@ -2617,11 +3024,11 @@ function registerSystemTools(server2, vaultPath2) {
|
|
|
2617
3024
|
}
|
|
2618
3025
|
const result2 = {
|
|
2619
3026
|
success: false,
|
|
2620
|
-
message: `Undo requires confirmation (confirm=true). Would undo: "${
|
|
3027
|
+
message: `Undo requires confirmation (confirm=true). Would undo: "${lastCommit2.message}"`,
|
|
2621
3028
|
path: "",
|
|
2622
|
-
preview: `Commit: ${
|
|
2623
|
-
Message: ${
|
|
2624
|
-
Date: ${
|
|
3029
|
+
preview: `Commit: ${lastCommit2.hash.substring(0, 7)}
|
|
3030
|
+
Message: ${lastCommit2.message}
|
|
3031
|
+
Date: ${lastCommit2.date}`
|
|
2625
3032
|
};
|
|
2626
3033
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2627
3034
|
}
|
|
@@ -2634,6 +3041,20 @@ Date: ${lastCommit.date}`
|
|
|
2634
3041
|
};
|
|
2635
3042
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2636
3043
|
}
|
|
3044
|
+
const lastCrankCommit = await getLastCrankCommit(vaultPath2);
|
|
3045
|
+
const lastCommit = await getLastCommit(vaultPath2);
|
|
3046
|
+
if (lastCrankCommit && lastCommit) {
|
|
3047
|
+
if (lastCommit.hash !== lastCrankCommit.hash) {
|
|
3048
|
+
const result2 = {
|
|
3049
|
+
success: false,
|
|
3050
|
+
message: `Cannot undo: HEAD (${lastCommit.hash.substring(0, 7)}) doesn't match last Crank commit (${lastCrankCommit.hash.substring(0, 7)}). Another process may have committed since your mutation.`,
|
|
3051
|
+
path: "",
|
|
3052
|
+
preview: `Expected: ${lastCrankCommit.hash.substring(0, 7)} "${lastCrankCommit.message}"
|
|
3053
|
+
Actual HEAD: ${lastCommit.hash.substring(0, 7)} "${lastCommit.message}"`
|
|
3054
|
+
};
|
|
3055
|
+
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
2637
3058
|
const undoResult = await undoLastCommit(vaultPath2);
|
|
2638
3059
|
if (!undoResult.success) {
|
|
2639
3060
|
const result2 = {
|
|
@@ -2643,6 +3064,7 @@ Date: ${lastCommit.date}`
|
|
|
2643
3064
|
};
|
|
2644
3065
|
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
2645
3066
|
}
|
|
3067
|
+
await clearLastCrankCommit(vaultPath2);
|
|
2646
3068
|
const result = {
|
|
2647
3069
|
success: true,
|
|
2648
3070
|
message: undoResult.message,
|
|
@@ -2665,7 +3087,7 @@ Message: ${undoResult.undoneCommit.message}` : void 0
|
|
|
2665
3087
|
}
|
|
2666
3088
|
|
|
2667
3089
|
// src/core/vaultRoot.ts
|
|
2668
|
-
import * as
|
|
3090
|
+
import * as fs8 from "fs";
|
|
2669
3091
|
import * as path10 from "path";
|
|
2670
3092
|
var VAULT_MARKERS = [".obsidian", ".claude"];
|
|
2671
3093
|
function findVaultRoot(startPath) {
|
|
@@ -2673,7 +3095,7 @@ function findVaultRoot(startPath) {
|
|
|
2673
3095
|
while (true) {
|
|
2674
3096
|
for (const marker of VAULT_MARKERS) {
|
|
2675
3097
|
const markerPath = path10.join(current, marker);
|
|
2676
|
-
if (
|
|
3098
|
+
if (fs8.existsSync(markerPath) && fs8.statSync(markerPath).isDirectory()) {
|
|
2677
3099
|
return current;
|
|
2678
3100
|
}
|
|
2679
3101
|
}
|