@tomingtoming/kioq 0.7.0
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/README.md +386 -0
- package/dist/config.js +174 -0
- package/dist/index.js +2 -0
- package/dist/normalizers.js +20 -0
- package/dist/noteStore.js +1115 -0
- package/dist/src/auditResponseSummary.js +160 -0
- package/dist/src/changeTriggerSummary.js +116 -0
- package/dist/src/config.js +192 -0
- package/dist/src/conflictSummary.js +7 -0
- package/dist/src/contractResponseSummary.js +43 -0
- package/dist/src/index.js +1305 -0
- package/dist/src/normalizers.js +20 -0
- package/dist/src/noteStore.js +3270 -0
- package/dist/src/operationImpact.js +68 -0
- package/dist/src/readResponseSummary.js +252 -0
- package/dist/src/responseMode.js +12 -0
- package/dist/src/storage/githubStorage.js +447 -0
- package/dist/src/storage/index.js +3 -0
- package/dist/src/storage/localFsStorage.js +125 -0
- package/dist/src/storage/types.js +4 -0
- package/dist/src/toolScope.js +20 -0
- package/dist/src/types.js +1 -0
- package/dist/src/writeResponseSummary.js +122 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,3270 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const EXPLORATION_TAG_STOPWORDS = new Set([
|
|
3
|
+
"flow",
|
|
4
|
+
"stock",
|
|
5
|
+
"general",
|
|
6
|
+
"index",
|
|
7
|
+
"moc",
|
|
8
|
+
"memory",
|
|
9
|
+
"note",
|
|
10
|
+
"notes",
|
|
11
|
+
]);
|
|
12
|
+
const MARKDOWN_EXTENSION = ".md";
|
|
13
|
+
const MAX_SIGNAL_ITEMS = 20;
|
|
14
|
+
const SEARCH_TERM_LIMIT = 48;
|
|
15
|
+
const SEARCH_NGRAM_LIMIT = 64;
|
|
16
|
+
const MEMORY_CONTRACT_VERSION = "1.12.0";
|
|
17
|
+
const MEMORY_CONTRACT_REQUIRED_FRONTMATTER = ["type", "tags", "created", "updated", "permalink"];
|
|
18
|
+
const MEMORY_CONTRACT_FLOW_REQUIRED_FRONTMATTER = ["memory_kind", "flow_state", "question", "next_action", "parent_stock"];
|
|
19
|
+
const MEMORY_CONTRACT_REQUIREMENTS = {
|
|
20
|
+
minResolvedLinks: 2,
|
|
21
|
+
minBacklinks: 1,
|
|
22
|
+
minParentLinks: 1,
|
|
23
|
+
minRelatedLinks: 1,
|
|
24
|
+
maxUnresolvedLinks: 0,
|
|
25
|
+
minTags: 1,
|
|
26
|
+
};
|
|
27
|
+
const MEMORY_CONTRACT_FLOW_REQUIREMENTS = {
|
|
28
|
+
minResolvedLinks: 2,
|
|
29
|
+
minBacklinks: 0,
|
|
30
|
+
minParentLinks: 1,
|
|
31
|
+
minRelatedLinks: 1,
|
|
32
|
+
maxUnresolvedLinks: 0,
|
|
33
|
+
minTags: 1,
|
|
34
|
+
};
|
|
35
|
+
const MEMORY_CONTRACT_PARENT_MARKERS = ["親", "parent", "上位", "moc", "index", "インデックス"];
|
|
36
|
+
const PARENT_LINE_PATTERN = /(?:\bparent\b|親|上位|moc|index|インデックス)/i;
|
|
37
|
+
const INDEX_TAG_MARKERS = ["index", "moc", "索引"];
|
|
38
|
+
const INDEX_TITLE_PATTERN = /(?:\bindex\b|\bmoc\b|インデックス|索引)/i;
|
|
39
|
+
const INDEX_SECTION_HEADINGS = ["目的", "テーマ別入口", "まず何を読むか"];
|
|
40
|
+
const INDEX_NAVIGATION_POLICY = {
|
|
41
|
+
excludedMemoryKinds: ["flow"],
|
|
42
|
+
signals: [
|
|
43
|
+
{ code: "index_tag", weight: 70, description: "tags に index / moc / 索引 を含む" },
|
|
44
|
+
{ code: "title_marker", weight: 45, description: "title に index marker を含む" },
|
|
45
|
+
{ code: "permalink_marker", weight: 35, description: "permalink に index marker を含む" },
|
|
46
|
+
{ code: "path_marker", weight: 25, description: "relative path に index marker を含む" },
|
|
47
|
+
{ code: "section:*", weight: 20, description: "index section を 1 件含むごとに加点する" },
|
|
48
|
+
{ code: "multi_index_sections", weight: 20, description: "index section が 2 件以上ある" },
|
|
49
|
+
{ code: "link_hub", weight: 10, description: "WikiLink が 4 件以上ある" },
|
|
50
|
+
],
|
|
51
|
+
thresholds: {
|
|
52
|
+
scoreAtLeast: 70,
|
|
53
|
+
indexSectionsAtLeast: 2,
|
|
54
|
+
},
|
|
55
|
+
configurableVia: {
|
|
56
|
+
env: ["KIOQ_INDEX_SCORE_THRESHOLD", "KIOQ_INDEX_SECTION_THRESHOLD"],
|
|
57
|
+
cli: ["--index-score-threshold", "--index-section-threshold"],
|
|
58
|
+
},
|
|
59
|
+
rules: [
|
|
60
|
+
"flow は title や section が index っぽくても index_like にしない",
|
|
61
|
+
"index_like_reasons には実際に発火した signal code だけを返す",
|
|
62
|
+
"この policy は導線用ヒューリスティクスであり、厳密な note type 判定ではない",
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
const FLOW_STATES = new Set(["capture", "active", "blocked", "done", "promoted", "dropped"]);
|
|
66
|
+
const STALE_FLOW_STATES = new Set(["capture", "active", "blocked"]);
|
|
67
|
+
const SINGLE_USE_TAG_EXACT_IGNORES = new Set(["kioq", "flow", "stock", "general", "index", "moc"]);
|
|
68
|
+
const SINGLE_USE_TAG_PATTERN_IGNORES = [/^batch-\d+$/];
|
|
69
|
+
const TITLE_BODY_EXACT_IGNORES = new Set(["抽出", "原則", "体験", "メモ", "体験メモ", "インデックス", "仕様化", "シグナル", "レビュー", "監査", "ガイド", "ノート", "再訪", "flow", "stock"]);
|
|
70
|
+
const CLARITY_SUMMARY_HEADINGS = new Set(["summary", "要約", "結論", "目的", "概要", "tl dr", "tl;dr"]);
|
|
71
|
+
const CLARITY_LOW_LIST_RATIO_THRESHOLD = 0.15;
|
|
72
|
+
const CLARITY_MIN_CONTENT_LINES_FOR_LIST_CHECK = 8;
|
|
73
|
+
const CLARITY_LONG_SENTENCE_THRESHOLD = 120;
|
|
74
|
+
const CLARITY_LONG_SENTENCE_ATTENTION_COUNT = 2;
|
|
75
|
+
const TECHNICAL_DEBT_POLICY = {
|
|
76
|
+
signals: [
|
|
77
|
+
"unresolved_wikilinks_delta",
|
|
78
|
+
"stale_flow_count",
|
|
79
|
+
"cleanup_ratio",
|
|
80
|
+
"cleanup_ready_count",
|
|
81
|
+
"attention_note_count",
|
|
82
|
+
"dependency_direction_violation_count",
|
|
83
|
+
"single_use_tag_candidate_count",
|
|
84
|
+
"title_body_mismatch_candidate_count",
|
|
85
|
+
],
|
|
86
|
+
staleFlowAgeBuckets: {
|
|
87
|
+
nearThreshold: "threshold..threshold+6 days",
|
|
88
|
+
aging: "threshold+7..threshold+22 days",
|
|
89
|
+
longStale: "threshold+23 days or more",
|
|
90
|
+
},
|
|
91
|
+
cleanupRatioDefinition: "今すぐ cleanup 不要なノート数 / 全ノート数",
|
|
92
|
+
cleanupReadyCountDefinition: "今すぐ cleanup 不要なノート数",
|
|
93
|
+
attentionNoteCountDefinition: "何らかの cleanup が必要なノート数",
|
|
94
|
+
dependencyDirectionViolationCountDefinition: "stock ノートが parent として flow を指している件数",
|
|
95
|
+
singleUseTagCandidateCountDefinition: "非 flow ノートで単発 tag を 2 件以上持つノート候補数(運用タグは除外)",
|
|
96
|
+
titleBodyMismatchCandidateCountDefinition: "非 flow ノートで title の中核語が本文見出し/要約に現れないノート候補数",
|
|
97
|
+
unresolvedWikilinksDeltaStatus: "requires_history",
|
|
98
|
+
configurableVia: {
|
|
99
|
+
env: ["KIOQ_STALE_FLOW_DAYS"],
|
|
100
|
+
cli: ["--stale-flow-days"],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const RESPONSE_MODE_POLICY = {
|
|
104
|
+
supportedTools: {
|
|
105
|
+
read: ["recent_notes", "search_notes", "read_note", "resolve_links", "list_backlinks", "context_bundle"],
|
|
106
|
+
audit: ["memory_contract", "lint_structure"],
|
|
107
|
+
},
|
|
108
|
+
modes: {
|
|
109
|
+
minimal: "summary と件数中心。cold start や高速周回向け",
|
|
110
|
+
standard: "既定。通常判断に必要な policy / breakdown を返す",
|
|
111
|
+
verbose: "詳細診断や sample template 本文まで返す",
|
|
112
|
+
},
|
|
113
|
+
rules: [
|
|
114
|
+
"まず minimal か standard で始め、必要になったときだけ verbose へ上げる",
|
|
115
|
+
"recommended_response_mode が返ったら、それを優先して detail level を上げる",
|
|
116
|
+
"sample template 本文や issue suggestion 全文が必要なときだけ verbose を使う",
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
const FLOW_DEFAULT_DIRECTORY = "Flow";
|
|
120
|
+
const STOCK_DEFAULT_DIRECTORY = "Stock";
|
|
121
|
+
const RESERVED_FRONTMATTER_KEYS = new Set(["title", "type", "permalink", "tags", "created", "updated"]);
|
|
122
|
+
const FLOW_QUESTION_HEADING_PATTERN = /^##\s+Question\s*$/im;
|
|
123
|
+
const FLOW_NEXT_ACTION_HEADING_PATTERN = /^##\s+Next Action\s*$/im;
|
|
124
|
+
const FLOW_PARENT_LINE_PATTERN = /^-\s*(?:親|parent):\s*\[\[/im;
|
|
125
|
+
const TOKEN_SPLIT_PATTERN = /[\s\p{P}\p{S}]+/u;
|
|
126
|
+
const HAS_TOKEN_CHAR_PATTERN = /[\p{L}\p{N}]/u;
|
|
127
|
+
const CJK_TOKEN_PATTERN = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+$/u;
|
|
128
|
+
const JA_WORD_SEGMENTER = (() => {
|
|
129
|
+
try {
|
|
130
|
+
return new Intl.Segmenter("ja", { granularity: "word" });
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
function pad2(value) {
|
|
137
|
+
return String(value).padStart(2, "0");
|
|
138
|
+
}
|
|
139
|
+
function formatLocalDate(date) {
|
|
140
|
+
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
141
|
+
}
|
|
142
|
+
function formatLocalTimestamp(date) {
|
|
143
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
144
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
145
|
+
const absoluteOffset = Math.abs(offsetMinutes);
|
|
146
|
+
const offsetHours = Math.floor(absoluteOffset / 60);
|
|
147
|
+
const offsetRemainderMinutes = absoluteOffset % 60;
|
|
148
|
+
return `${formatLocalDate(date)}T${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}${sign}${pad2(offsetHours)}:${pad2(offsetRemainderMinutes)}`;
|
|
149
|
+
}
|
|
150
|
+
function nowDate() {
|
|
151
|
+
return formatLocalDate(new Date());
|
|
152
|
+
}
|
|
153
|
+
function nowTimestamp() {
|
|
154
|
+
return formatLocalTimestamp(new Date());
|
|
155
|
+
}
|
|
156
|
+
function parseDateOnly(value) {
|
|
157
|
+
const trimmed = value?.trim();
|
|
158
|
+
if (!trimmed || !/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
const timestamp = Date.parse(`${trimmed}T00:00:00Z`);
|
|
162
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
163
|
+
}
|
|
164
|
+
function daysBetween(startDate, endDate) {
|
|
165
|
+
const start = parseDateOnly(startDate);
|
|
166
|
+
const end = parseDateOnly(endDate);
|
|
167
|
+
if (start === undefined || end === undefined) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return Math.floor((end - start) / 86_400_000);
|
|
171
|
+
}
|
|
172
|
+
function stripQuotes(value) {
|
|
173
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
174
|
+
return value.slice(1, -1);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function emptyStaleFlowAgeBuckets() {
|
|
179
|
+
return {
|
|
180
|
+
nearThreshold: 0,
|
|
181
|
+
aging: 0,
|
|
182
|
+
longStale: 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function sanitizeRelativePath(value) {
|
|
186
|
+
if (!value) {
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
const normalized = value
|
|
190
|
+
.replace(/\\/g, "/")
|
|
191
|
+
.split("/")
|
|
192
|
+
.map((part) => part.trim())
|
|
193
|
+
.filter((part) => part.length > 0)
|
|
194
|
+
.join("/");
|
|
195
|
+
if (normalized.length === 0) {
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
const segments = normalized.split("/");
|
|
199
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
200
|
+
throw new Error(`invalid relative path: ${value}`);
|
|
201
|
+
}
|
|
202
|
+
return segments.join("/");
|
|
203
|
+
}
|
|
204
|
+
function sanitizeFileBase(title) {
|
|
205
|
+
const trimmed = title.trim();
|
|
206
|
+
const replaced = trimmed
|
|
207
|
+
.replace(/[\\/:*?"<>|]/g, "-")
|
|
208
|
+
.replace(/\s+/g, " ")
|
|
209
|
+
.replace(/^\.+/, "")
|
|
210
|
+
.replace(/\.+$/, "")
|
|
211
|
+
.trim();
|
|
212
|
+
if (replaced.length === 0) {
|
|
213
|
+
return "untitled";
|
|
214
|
+
}
|
|
215
|
+
return replaced;
|
|
216
|
+
}
|
|
217
|
+
function inferTitleFromIdentifier(identifier) {
|
|
218
|
+
const trimmed = identifier.trim();
|
|
219
|
+
if (trimmed.length === 0) {
|
|
220
|
+
return "untitled";
|
|
221
|
+
}
|
|
222
|
+
const normalized = trimmed.replace(/\\/g, "/");
|
|
223
|
+
const last = normalized.split("/").filter((part) => part.length > 0).pop() ?? normalized;
|
|
224
|
+
const withoutExtension = last.replace(/\.md$/i, "").trim();
|
|
225
|
+
return withoutExtension.length > 0 ? withoutExtension : "untitled";
|
|
226
|
+
}
|
|
227
|
+
function isInsideDirectory(baseDir, targetPath) {
|
|
228
|
+
const normalizedBase = path.resolve(baseDir);
|
|
229
|
+
const normalizedTarget = path.resolve(targetPath);
|
|
230
|
+
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}${path.sep}`);
|
|
231
|
+
}
|
|
232
|
+
function parseFrontmatterBlock(block) {
|
|
233
|
+
const result = {};
|
|
234
|
+
const lines = block.split(/\r?\n/);
|
|
235
|
+
let currentArrayKey;
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const listMatch = line.match(/^\s*-\s*(.+)$/);
|
|
238
|
+
if (listMatch && currentArrayKey) {
|
|
239
|
+
const current = result[currentArrayKey];
|
|
240
|
+
if (Array.isArray(current)) {
|
|
241
|
+
current.push(stripQuotes(listMatch[1].trim()));
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
currentArrayKey = undefined;
|
|
246
|
+
const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
247
|
+
if (!keyValueMatch) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const key = keyValueMatch[1];
|
|
251
|
+
const rawValue = keyValueMatch[2].trim();
|
|
252
|
+
if (rawValue.length === 0) {
|
|
253
|
+
result[key] = [];
|
|
254
|
+
currentArrayKey = key;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
258
|
+
const inner = rawValue.slice(1, -1).trim();
|
|
259
|
+
result[key] = inner.length === 0
|
|
260
|
+
? []
|
|
261
|
+
: inner.split(",").map((item) => stripQuotes(item.trim())).filter((item) => item.length > 0);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
result[key] = stripQuotes(rawValue);
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
function splitFrontmatter(raw) {
|
|
269
|
+
if (!raw.startsWith("---\n")) {
|
|
270
|
+
return { frontmatter: {}, body: raw };
|
|
271
|
+
}
|
|
272
|
+
const separatorIndex = raw.indexOf("\n---\n", 4);
|
|
273
|
+
if (separatorIndex < 0) {
|
|
274
|
+
return { frontmatter: {}, body: raw };
|
|
275
|
+
}
|
|
276
|
+
const block = raw.slice(4, separatorIndex);
|
|
277
|
+
const body = raw.slice(separatorIndex + 5);
|
|
278
|
+
return {
|
|
279
|
+
frontmatter: parseFrontmatterBlock(block),
|
|
280
|
+
body,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function formatFrontmatterValue(value) {
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
if (value.length === 0) {
|
|
286
|
+
return "[]";
|
|
287
|
+
}
|
|
288
|
+
return `\n${value.map((item) => `- ${JSON.stringify(item)}`).join("\n")}`;
|
|
289
|
+
}
|
|
290
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(value)) {
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
return JSON.stringify(value);
|
|
294
|
+
}
|
|
295
|
+
function buildFrontmatter(frontmatter) {
|
|
296
|
+
const preferredOrder = [
|
|
297
|
+
"title",
|
|
298
|
+
"type",
|
|
299
|
+
"memory_kind",
|
|
300
|
+
"flow_state",
|
|
301
|
+
"permalink",
|
|
302
|
+
"parent_stock",
|
|
303
|
+
"tags",
|
|
304
|
+
"created",
|
|
305
|
+
"updated",
|
|
306
|
+
];
|
|
307
|
+
const orderedKeys = [
|
|
308
|
+
...preferredOrder.filter((key) => key in frontmatter),
|
|
309
|
+
...Object.keys(frontmatter).filter((key) => !preferredOrder.includes(key)),
|
|
310
|
+
];
|
|
311
|
+
const lines = orderedKeys.map((key) => `${key}: ${formatFrontmatterValue(frontmatter[key])}`);
|
|
312
|
+
return `---\n${lines.join("\n")}\n---`;
|
|
313
|
+
}
|
|
314
|
+
function ensureTrailingNewline(text) {
|
|
315
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
316
|
+
}
|
|
317
|
+
function normalizeContent(text) {
|
|
318
|
+
return text.replace(/\r\n/g, "\n");
|
|
319
|
+
}
|
|
320
|
+
function normalizeLookup(value) {
|
|
321
|
+
return value.toLowerCase().replace(/[\s._/-]+/g, "");
|
|
322
|
+
}
|
|
323
|
+
function katakanaToHiragana(value) {
|
|
324
|
+
return Array.from(value)
|
|
325
|
+
.map((char) => {
|
|
326
|
+
const code = char.charCodeAt(0);
|
|
327
|
+
if (code >= 0x30A1 && code <= 0x30F6) {
|
|
328
|
+
return String.fromCharCode(code - 0x60);
|
|
329
|
+
}
|
|
330
|
+
return char;
|
|
331
|
+
})
|
|
332
|
+
.join("");
|
|
333
|
+
}
|
|
334
|
+
function normalizeForSearch(value) {
|
|
335
|
+
return katakanaToHiragana(value.normalize("NFKC"))
|
|
336
|
+
.toLowerCase()
|
|
337
|
+
.replace(/\s+/g, " ")
|
|
338
|
+
.trim();
|
|
339
|
+
}
|
|
340
|
+
function compactSearchText(value) {
|
|
341
|
+
return value.replace(/\s+/g, "");
|
|
342
|
+
}
|
|
343
|
+
function stringLength(value) {
|
|
344
|
+
return Array.from(value).length;
|
|
345
|
+
}
|
|
346
|
+
function isCjkToken(value) {
|
|
347
|
+
return value.length > 0 && CJK_TOKEN_PATTERN.test(value);
|
|
348
|
+
}
|
|
349
|
+
function cjkNgrams(value, minLength, maxLength, limit) {
|
|
350
|
+
const chars = Array.from(value);
|
|
351
|
+
if (chars.length < minLength) {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
const grams = [];
|
|
355
|
+
for (let size = minLength; size <= maxLength; size += 1) {
|
|
356
|
+
if (size > chars.length) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (let index = 0; index <= chars.length - size; index += 1) {
|
|
360
|
+
grams.push(chars.slice(index, index + size).join(""));
|
|
361
|
+
if (grams.length >= limit) {
|
|
362
|
+
return grams;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return grams;
|
|
367
|
+
}
|
|
368
|
+
function searchTerms(query) {
|
|
369
|
+
const normalized = normalizeForSearch(query);
|
|
370
|
+
if (normalized.length === 0) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
const terms = [];
|
|
374
|
+
const seen = new Set();
|
|
375
|
+
const pushTerm = (term) => {
|
|
376
|
+
const cleaned = term.trim();
|
|
377
|
+
if (cleaned.length === 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!HAS_TOKEN_CHAR_PATTERN.test(cleaned)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (seen.has(cleaned)) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
seen.add(cleaned);
|
|
387
|
+
terms.push(cleaned);
|
|
388
|
+
};
|
|
389
|
+
pushTerm(normalized);
|
|
390
|
+
normalized
|
|
391
|
+
.split(TOKEN_SPLIT_PATTERN)
|
|
392
|
+
.filter((token) => token.length > 0)
|
|
393
|
+
.forEach(pushTerm);
|
|
394
|
+
if (JA_WORD_SEGMENTER) {
|
|
395
|
+
for (const piece of JA_WORD_SEGMENTER.segment(normalized)) {
|
|
396
|
+
const segment = piece.segment.trim();
|
|
397
|
+
if (segment.length === 0) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const wordLike = "isWordLike" in piece ? Boolean(piece.isWordLike) : true;
|
|
401
|
+
if (!wordLike) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
pushTerm(segment);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const hasMultiCharTerm = terms.some((term) => stringLength(term) >= 2);
|
|
408
|
+
const filteredTerms = hasMultiCharTerm ? terms.filter((term) => stringLength(term) >= 2) : terms;
|
|
409
|
+
const ngrams = [];
|
|
410
|
+
const ngramSeen = new Set();
|
|
411
|
+
for (const term of filteredTerms) {
|
|
412
|
+
if (!isCjkToken(term) || stringLength(term) < 3) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
for (const gram of cjkNgrams(term, 2, 3, SEARCH_NGRAM_LIMIT)) {
|
|
416
|
+
if (ngramSeen.has(gram)) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (seen.has(gram)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
ngramSeen.add(gram);
|
|
423
|
+
ngrams.push(gram);
|
|
424
|
+
if (ngrams.length >= SEARCH_NGRAM_LIMIT) {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (ngrams.length >= SEARCH_NGRAM_LIMIT) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return [...filteredTerms, ...ngrams].slice(0, SEARCH_TERM_LIMIT);
|
|
433
|
+
}
|
|
434
|
+
function safeString(value) {
|
|
435
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
436
|
+
}
|
|
437
|
+
function safeStringArray(value) {
|
|
438
|
+
if (!Array.isArray(value)) {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
return value
|
|
442
|
+
.filter((item) => typeof item === "string")
|
|
443
|
+
.map((item) => item.trim())
|
|
444
|
+
.filter((item) => item.length > 0);
|
|
445
|
+
}
|
|
446
|
+
function normalizeFrontmatterPatch(input) {
|
|
447
|
+
if (!input) {
|
|
448
|
+
return {};
|
|
449
|
+
}
|
|
450
|
+
const normalized = {};
|
|
451
|
+
for (const [key, value] of Object.entries(input)) {
|
|
452
|
+
const trimmedKey = key.trim();
|
|
453
|
+
if (!trimmedKey) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (Array.isArray(value)) {
|
|
457
|
+
const next = value.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
458
|
+
normalized[trimmedKey] = next;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const next = value.trim();
|
|
462
|
+
if (next.length === 0) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
normalized[trimmedKey] = next;
|
|
466
|
+
}
|
|
467
|
+
return normalized;
|
|
468
|
+
}
|
|
469
|
+
function normalizeWikiTargetInput(value) {
|
|
470
|
+
const trimmed = value.trim();
|
|
471
|
+
if (trimmed.length === 0) {
|
|
472
|
+
return "";
|
|
473
|
+
}
|
|
474
|
+
let inner = trimmed;
|
|
475
|
+
if (inner.startsWith("[[") && inner.endsWith("]]")) {
|
|
476
|
+
inner = inner.slice(2, -2).trim();
|
|
477
|
+
}
|
|
478
|
+
const pipeIndex = inner.indexOf("|");
|
|
479
|
+
if (pipeIndex >= 0) {
|
|
480
|
+
inner = inner.slice(0, pipeIndex).trim();
|
|
481
|
+
}
|
|
482
|
+
return splitWikiTarget(inner).base;
|
|
483
|
+
}
|
|
484
|
+
function uniqueStrings(values) {
|
|
485
|
+
return Array.from(new Set(values));
|
|
486
|
+
}
|
|
487
|
+
function intersectCount(left, right) {
|
|
488
|
+
let count = 0;
|
|
489
|
+
for (const value of left) {
|
|
490
|
+
if (right.has(value)) {
|
|
491
|
+
count += 1;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return count;
|
|
495
|
+
}
|
|
496
|
+
function extractSectionBody(markdown, heading) {
|
|
497
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
498
|
+
const pattern = new RegExp(`^##\\s+${escaped}\\s*$`, "im");
|
|
499
|
+
const startMatch = pattern.exec(markdown);
|
|
500
|
+
if (!startMatch) {
|
|
501
|
+
return "";
|
|
502
|
+
}
|
|
503
|
+
const afterHeading = markdown.slice(startMatch.index + startMatch[0].length).replace(/^\n+/, "");
|
|
504
|
+
const nextHeadingMatch = /^##\s+/m.exec(afterHeading);
|
|
505
|
+
const section = nextHeadingMatch ? afterHeading.slice(0, nextHeadingMatch.index) : afterHeading;
|
|
506
|
+
return section.trim();
|
|
507
|
+
}
|
|
508
|
+
function excerptBody(markdown, maxLines, maxChars) {
|
|
509
|
+
const lines = normalizeContent(markdown)
|
|
510
|
+
.split("\n")
|
|
511
|
+
.map((line) => line.trim())
|
|
512
|
+
.filter((line) => line.length > 0)
|
|
513
|
+
.filter((line) => !line.startsWith("#"))
|
|
514
|
+
.filter((line) => !line.startsWith("- 親:"))
|
|
515
|
+
.filter((line) => !line.startsWith("- 関連:"));
|
|
516
|
+
const picked = [];
|
|
517
|
+
let total = 0;
|
|
518
|
+
for (const line of lines) {
|
|
519
|
+
const nextLength = total + line.length + (picked.length > 0 ? 1 : 0);
|
|
520
|
+
if (picked.length >= maxLines || nextLength > maxChars) {
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
picked.push(line);
|
|
524
|
+
total = nextLength;
|
|
525
|
+
}
|
|
526
|
+
return picked.join("\n").trim();
|
|
527
|
+
}
|
|
528
|
+
function markdownHeadings(markdown) {
|
|
529
|
+
const headings = [];
|
|
530
|
+
const pattern = /^##+\s+(.+?)\s*$/gm;
|
|
531
|
+
let match;
|
|
532
|
+
while ((match = pattern.exec(normalizeContent(markdown))) !== null) {
|
|
533
|
+
const heading = match[1]?.trim();
|
|
534
|
+
if (heading) {
|
|
535
|
+
headings.push(heading);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return headings;
|
|
539
|
+
}
|
|
540
|
+
function clarityContentLines(markdown) {
|
|
541
|
+
return normalizeContent(markdown)
|
|
542
|
+
.split("\n")
|
|
543
|
+
.map((line) => line.trim())
|
|
544
|
+
.filter((line) => line.length > 0)
|
|
545
|
+
.filter((line) => !line.startsWith("#"))
|
|
546
|
+
.filter((line) => !line.startsWith("- 親:"))
|
|
547
|
+
.filter((line) => !line.startsWith("- 関連:"));
|
|
548
|
+
}
|
|
549
|
+
function summaryHeadingPresent(markdown) {
|
|
550
|
+
return markdownHeadings(markdown).some((heading) => {
|
|
551
|
+
const normalized = normalizeForSearch(heading).replace(/\s+/g, " ").trim();
|
|
552
|
+
return CLARITY_SUMMARY_HEADINGS.has(normalized);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function listRatioForMarkdown(markdown) {
|
|
556
|
+
const lines = clarityContentLines(markdown);
|
|
557
|
+
if (lines.length === 0) {
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
const listCount = lines.filter((line) => /^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line)).length;
|
|
561
|
+
return Number((listCount / lines.length).toFixed(3));
|
|
562
|
+
}
|
|
563
|
+
function longSentenceCountForMarkdown(markdown) {
|
|
564
|
+
const text = clarityContentLines(markdown)
|
|
565
|
+
.filter((line) => !/^[-*]\s+/.test(line))
|
|
566
|
+
.filter((line) => !/^\d+\.\s+/.test(line))
|
|
567
|
+
.join(" ")
|
|
568
|
+
.replace(/\s+/g, " ")
|
|
569
|
+
.trim();
|
|
570
|
+
if (!text) {
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
return text
|
|
574
|
+
.split(/[。!?.!?]+/u)
|
|
575
|
+
.map((sentence) => sentence.trim())
|
|
576
|
+
.filter((sentence) => sentence.length >= CLARITY_LONG_SENTENCE_THRESHOLD)
|
|
577
|
+
.length;
|
|
578
|
+
}
|
|
579
|
+
function titleBodySemanticTerms(value) {
|
|
580
|
+
const normalized = normalizeForSearch(value)
|
|
581
|
+
.replace(/\b\d{4}-\d{2}-\d{2}\b/g, " ")
|
|
582
|
+
.replace(/[_/.-]+/g, " ")
|
|
583
|
+
.trim();
|
|
584
|
+
if (!normalized) {
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
const rawTerms = [];
|
|
588
|
+
const pushTerm = (term) => {
|
|
589
|
+
const cleaned = term.trim();
|
|
590
|
+
if (!cleaned || !HAS_TOKEN_CHAR_PATTERN.test(cleaned)) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (/^\d+$/.test(cleaned)) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (TITLE_BODY_EXACT_IGNORES.has(cleaned)) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const length = stringLength(cleaned);
|
|
600
|
+
if (isCjkToken(cleaned) ? length < 2 : length < 2) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
rawTerms.push(cleaned);
|
|
604
|
+
};
|
|
605
|
+
if (JA_WORD_SEGMENTER) {
|
|
606
|
+
for (const piece of JA_WORD_SEGMENTER.segment(normalized)) {
|
|
607
|
+
const segment = piece.segment.trim();
|
|
608
|
+
const wordLike = "isWordLike" in piece ? Boolean(piece.isWordLike) : true;
|
|
609
|
+
if (!wordLike) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
pushTerm(segment);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
normalized
|
|
617
|
+
.split(TOKEN_SPLIT_PATTERN)
|
|
618
|
+
.filter((token) => token.length > 0)
|
|
619
|
+
.forEach(pushTerm);
|
|
620
|
+
}
|
|
621
|
+
return uniqueStrings(rawTerms);
|
|
622
|
+
}
|
|
623
|
+
function countOccurrences(text, query) {
|
|
624
|
+
if (!query || !text) {
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
let count = 0;
|
|
628
|
+
let index = 0;
|
|
629
|
+
while (true) {
|
|
630
|
+
const foundIndex = text.indexOf(query, index);
|
|
631
|
+
if (foundIndex < 0) {
|
|
632
|
+
return count;
|
|
633
|
+
}
|
|
634
|
+
count += 1;
|
|
635
|
+
index = foundIndex + query.length;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function identifierMatchScore(key, query) {
|
|
639
|
+
const keyLower = key.toLowerCase();
|
|
640
|
+
const queryLower = query.toLowerCase();
|
|
641
|
+
if (!keyLower || !queryLower) {
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
if (keyLower === queryLower) {
|
|
645
|
+
return 1000;
|
|
646
|
+
}
|
|
647
|
+
const keyNormalized = normalizeLookup(keyLower);
|
|
648
|
+
const queryNormalized = normalizeLookup(queryLower);
|
|
649
|
+
if (keyNormalized.length > 0 && keyNormalized === queryNormalized) {
|
|
650
|
+
return 900;
|
|
651
|
+
}
|
|
652
|
+
if (keyLower.includes(queryLower)) {
|
|
653
|
+
return Math.max(400, 700 - Math.max(0, keyLower.length - queryLower.length));
|
|
654
|
+
}
|
|
655
|
+
if (queryLower.includes(keyLower) && keyLower.length >= 4) {
|
|
656
|
+
return Math.max(250, 420 - Math.max(0, queryLower.length - keyLower.length));
|
|
657
|
+
}
|
|
658
|
+
if (queryNormalized.length > 0 && keyNormalized.includes(queryNormalized)) {
|
|
659
|
+
return Math.max(220, 520 - Math.max(0, keyNormalized.length - queryNormalized.length));
|
|
660
|
+
}
|
|
661
|
+
if (keyNormalized.length >= 4 && queryNormalized.includes(keyNormalized)) {
|
|
662
|
+
return Math.max(140, 320 - Math.max(0, queryNormalized.length - keyNormalized.length));
|
|
663
|
+
}
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
function normalizeHintKey(value) {
|
|
667
|
+
return normalizeLookup(normalizeForSearch(value));
|
|
668
|
+
}
|
|
669
|
+
function levenshteinDistance(left, right, maxDistance) {
|
|
670
|
+
const leftChars = Array.from(left);
|
|
671
|
+
const rightChars = Array.from(right);
|
|
672
|
+
if (Math.abs(leftChars.length - rightChars.length) > maxDistance) {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
let previous = Array.from({ length: rightChars.length + 1 }, (_, index) => index);
|
|
676
|
+
let current = new Array(rightChars.length + 1).fill(0);
|
|
677
|
+
for (let i = 1; i <= leftChars.length; i += 1) {
|
|
678
|
+
current[0] = i;
|
|
679
|
+
let rowMin = current[0];
|
|
680
|
+
for (let j = 1; j <= rightChars.length; j += 1) {
|
|
681
|
+
const cost = leftChars[i - 1] === rightChars[j - 1] ? 0 : 1;
|
|
682
|
+
const value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
683
|
+
current[j] = value;
|
|
684
|
+
rowMin = Math.min(rowMin, value);
|
|
685
|
+
}
|
|
686
|
+
if (rowMin > maxDistance) {
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
[previous, current] = [current, previous];
|
|
690
|
+
}
|
|
691
|
+
const distance = previous[rightChars.length];
|
|
692
|
+
return distance <= maxDistance ? distance : undefined;
|
|
693
|
+
}
|
|
694
|
+
function fuzzyIdentifierScore(key, query) {
|
|
695
|
+
const keyNormalized = normalizeHintKey(key);
|
|
696
|
+
const queryNormalized = normalizeHintKey(query);
|
|
697
|
+
if (keyNormalized.length === 0 || queryNormalized.length === 0) {
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
if (keyNormalized === queryNormalized) {
|
|
701
|
+
return 780;
|
|
702
|
+
}
|
|
703
|
+
const keyLength = stringLength(keyNormalized);
|
|
704
|
+
const queryLength = stringLength(queryNormalized);
|
|
705
|
+
const maxLength = Math.max(keyLength, queryLength);
|
|
706
|
+
if (maxLength < 3) {
|
|
707
|
+
return 0;
|
|
708
|
+
}
|
|
709
|
+
const maxDistance = Math.max(1, Math.floor(maxLength * 0.34));
|
|
710
|
+
const distance = levenshteinDistance(keyNormalized, queryNormalized, maxDistance);
|
|
711
|
+
if (distance === undefined) {
|
|
712
|
+
return 0;
|
|
713
|
+
}
|
|
714
|
+
const similarity = 1 - distance / maxLength;
|
|
715
|
+
let score = 0;
|
|
716
|
+
if (similarity >= 0.92) {
|
|
717
|
+
score = 520;
|
|
718
|
+
}
|
|
719
|
+
else if (similarity >= 0.84) {
|
|
720
|
+
score = 420;
|
|
721
|
+
}
|
|
722
|
+
else if (similarity >= 0.74) {
|
|
723
|
+
score = 300;
|
|
724
|
+
}
|
|
725
|
+
else if (similarity >= 0.64) {
|
|
726
|
+
score = 200;
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
return 0;
|
|
730
|
+
}
|
|
731
|
+
if (keyNormalized.startsWith(queryNormalized) || queryNormalized.startsWith(keyNormalized)) {
|
|
732
|
+
score += 30;
|
|
733
|
+
}
|
|
734
|
+
return score;
|
|
735
|
+
}
|
|
736
|
+
function splitWikiTarget(targetRaw) {
|
|
737
|
+
const trimmed = targetRaw.trim();
|
|
738
|
+
if (trimmed.length === 0) {
|
|
739
|
+
return { base: "", suffix: "" };
|
|
740
|
+
}
|
|
741
|
+
const hashIndex = trimmed.indexOf("#");
|
|
742
|
+
const blockIndex = trimmed.indexOf("^");
|
|
743
|
+
let splitIndex = -1;
|
|
744
|
+
if (hashIndex >= 0 && blockIndex >= 0) {
|
|
745
|
+
splitIndex = Math.min(hashIndex, blockIndex);
|
|
746
|
+
}
|
|
747
|
+
else if (hashIndex >= 0) {
|
|
748
|
+
splitIndex = hashIndex;
|
|
749
|
+
}
|
|
750
|
+
else if (blockIndex >= 0) {
|
|
751
|
+
splitIndex = blockIndex;
|
|
752
|
+
}
|
|
753
|
+
if (splitIndex < 0) {
|
|
754
|
+
return { base: trimmed, suffix: "" };
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
base: trimmed.slice(0, splitIndex).trim(),
|
|
758
|
+
suffix: trimmed.slice(splitIndex),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function extractWikiLinks(text) {
|
|
762
|
+
const pattern = /\[\[([^\]\n]+)\]\]/g;
|
|
763
|
+
const tokens = [];
|
|
764
|
+
let match;
|
|
765
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
766
|
+
const raw = match[0];
|
|
767
|
+
const inner = match[1].trim();
|
|
768
|
+
const pipeIndex = inner.indexOf("|");
|
|
769
|
+
const targetRaw = (pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner).trim();
|
|
770
|
+
const alias = pipeIndex >= 0 ? inner.slice(pipeIndex + 1) : undefined;
|
|
771
|
+
const { base, suffix } = splitWikiTarget(targetRaw);
|
|
772
|
+
tokens.push({
|
|
773
|
+
raw,
|
|
774
|
+
inner,
|
|
775
|
+
targetRaw,
|
|
776
|
+
targetBase: base,
|
|
777
|
+
suffix,
|
|
778
|
+
alias,
|
|
779
|
+
start: match.index,
|
|
780
|
+
end: match.index + raw.length,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return tokens;
|
|
784
|
+
}
|
|
785
|
+
function renderWikiLink(targetBase, suffix, alias) {
|
|
786
|
+
const target = `${targetBase}${suffix}`;
|
|
787
|
+
if (alias === undefined) {
|
|
788
|
+
return `[[${target}]]`;
|
|
789
|
+
}
|
|
790
|
+
return `[[${target}|${alias}]]`;
|
|
791
|
+
}
|
|
792
|
+
function rewriteWikiLinks(text, replacer) {
|
|
793
|
+
const tokens = extractWikiLinks(text);
|
|
794
|
+
if (tokens.length === 0) {
|
|
795
|
+
return { text, replaced: 0 };
|
|
796
|
+
}
|
|
797
|
+
let out = "";
|
|
798
|
+
let replaced = 0;
|
|
799
|
+
let lastIndex = 0;
|
|
800
|
+
for (const token of tokens) {
|
|
801
|
+
out += text.slice(lastIndex, token.start);
|
|
802
|
+
const next = replacer(token);
|
|
803
|
+
if (next !== undefined && next !== token.raw) {
|
|
804
|
+
out += next;
|
|
805
|
+
replaced += 1;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
out += token.raw;
|
|
809
|
+
}
|
|
810
|
+
lastIndex = token.end;
|
|
811
|
+
}
|
|
812
|
+
out += text.slice(lastIndex);
|
|
813
|
+
return { text: out, replaced };
|
|
814
|
+
}
|
|
815
|
+
function lineAtOffset(text, offset) {
|
|
816
|
+
const safeOffset = Math.max(0, Math.min(offset, text.length));
|
|
817
|
+
const lineStart = text.lastIndexOf("\n", safeOffset - 1);
|
|
818
|
+
const lineEnd = text.indexOf("\n", safeOffset);
|
|
819
|
+
const start = lineStart >= 0 ? lineStart + 1 : 0;
|
|
820
|
+
const end = lineEnd >= 0 ? lineEnd : text.length;
|
|
821
|
+
return text.slice(start, end).trim();
|
|
822
|
+
}
|
|
823
|
+
function linePrefixAtOffset(text, offset) {
|
|
824
|
+
const safeOffset = Math.max(0, Math.min(offset, text.length));
|
|
825
|
+
const lineStart = text.lastIndexOf("\n", safeOffset - 1);
|
|
826
|
+
const start = lineStart >= 0 ? lineStart + 1 : 0;
|
|
827
|
+
return text.slice(start, safeOffset).trim();
|
|
828
|
+
}
|
|
829
|
+
function uniqueByFilePath(notes) {
|
|
830
|
+
const seen = new Set();
|
|
831
|
+
const unique = [];
|
|
832
|
+
for (const note of notes) {
|
|
833
|
+
if (seen.has(note.filePath)) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
seen.add(note.filePath);
|
|
837
|
+
unique.push(note);
|
|
838
|
+
}
|
|
839
|
+
return unique;
|
|
840
|
+
}
|
|
841
|
+
export class LocalNoteStore {
|
|
842
|
+
config;
|
|
843
|
+
storage;
|
|
844
|
+
root;
|
|
845
|
+
projectName;
|
|
846
|
+
constructor(config, storage) {
|
|
847
|
+
this.config = config;
|
|
848
|
+
this.storage = storage;
|
|
849
|
+
this.root = config.root;
|
|
850
|
+
this.projectName = path.basename(this.root);
|
|
851
|
+
}
|
|
852
|
+
identifierKeys(note) {
|
|
853
|
+
const relativeNoExt = note.relativePath.endsWith(MARKDOWN_EXTENSION)
|
|
854
|
+
? note.relativePath.slice(0, -MARKDOWN_EXTENSION.length)
|
|
855
|
+
: note.relativePath;
|
|
856
|
+
return [
|
|
857
|
+
note.title,
|
|
858
|
+
note.permalink,
|
|
859
|
+
note.relativePath,
|
|
860
|
+
relativeNoExt,
|
|
861
|
+
path.basename(note.filePath, MARKDOWN_EXTENSION),
|
|
862
|
+
];
|
|
863
|
+
}
|
|
864
|
+
candidateKeysFromTarget(targetBase) {
|
|
865
|
+
const trimmed = targetBase.trim();
|
|
866
|
+
if (trimmed.length === 0) {
|
|
867
|
+
return [];
|
|
868
|
+
}
|
|
869
|
+
const normalizedPath = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
870
|
+
const withoutExtension = normalizedPath.endsWith(MARKDOWN_EXTENSION)
|
|
871
|
+
? normalizedPath.slice(0, -MARKDOWN_EXTENSION.length)
|
|
872
|
+
: normalizedPath;
|
|
873
|
+
const baseNoExt = path.posix.basename(withoutExtension);
|
|
874
|
+
const baseWithExt = path.posix.basename(normalizedPath);
|
|
875
|
+
return Array.from(new Set([normalizedPath, withoutExtension, baseNoExt, baseWithExt].filter((key) => key.length > 0)));
|
|
876
|
+
}
|
|
877
|
+
addToLookup(map, key, note) {
|
|
878
|
+
const normalizedKey = key.trim();
|
|
879
|
+
if (normalizedKey.length === 0) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const current = map.get(normalizedKey) ?? [];
|
|
883
|
+
if (!current.some((item) => item.filePath === note.filePath)) {
|
|
884
|
+
current.push(note);
|
|
885
|
+
map.set(normalizedKey, current);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
buildLookup(notes) {
|
|
889
|
+
const exact = new Map();
|
|
890
|
+
const normalized = new Map();
|
|
891
|
+
for (const note of notes) {
|
|
892
|
+
const keys = this.identifierKeys(note);
|
|
893
|
+
for (const key of keys) {
|
|
894
|
+
this.addToLookup(exact, key.toLowerCase(), note);
|
|
895
|
+
this.addToLookup(normalized, normalizeLookup(key), note);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return { exact, normalized };
|
|
899
|
+
}
|
|
900
|
+
resolveWikiTarget(targetBase, lookup) {
|
|
901
|
+
const keys = this.candidateKeysFromTarget(targetBase);
|
|
902
|
+
if (keys.length === 0) {
|
|
903
|
+
return { status: "unresolved", reason: "empty_target" };
|
|
904
|
+
}
|
|
905
|
+
const exactCandidates = [];
|
|
906
|
+
for (const key of keys) {
|
|
907
|
+
exactCandidates.push(...(lookup.exact.get(key.toLowerCase()) ?? []));
|
|
908
|
+
}
|
|
909
|
+
const uniqueExact = uniqueByFilePath(exactCandidates);
|
|
910
|
+
if (uniqueExact.length === 1) {
|
|
911
|
+
return { status: "resolved", note: uniqueExact[0] };
|
|
912
|
+
}
|
|
913
|
+
if (uniqueExact.length > 1) {
|
|
914
|
+
return { status: "ambiguous", reason: "multiple_exact_matches" };
|
|
915
|
+
}
|
|
916
|
+
const normalizedCandidates = [];
|
|
917
|
+
for (const key of keys) {
|
|
918
|
+
normalizedCandidates.push(...(lookup.normalized.get(normalizeLookup(key)) ?? []));
|
|
919
|
+
}
|
|
920
|
+
const uniqueNormalized = uniqueByFilePath(normalizedCandidates);
|
|
921
|
+
if (uniqueNormalized.length === 1) {
|
|
922
|
+
return { status: "resolved", note: uniqueNormalized[0] };
|
|
923
|
+
}
|
|
924
|
+
if (uniqueNormalized.length > 1) {
|
|
925
|
+
return { status: "ambiguous", reason: "multiple_normalized_matches" };
|
|
926
|
+
}
|
|
927
|
+
return { status: "unresolved", reason: "target_not_found" };
|
|
928
|
+
}
|
|
929
|
+
resolveWikiLinksForText(text, lookup) {
|
|
930
|
+
const tokens = extractWikiLinks(text);
|
|
931
|
+
return tokens.map((token) => {
|
|
932
|
+
const resolved = this.resolveWikiTarget(token.targetBase, lookup);
|
|
933
|
+
if (resolved.status === "resolved" && resolved.note) {
|
|
934
|
+
return {
|
|
935
|
+
raw: token.raw,
|
|
936
|
+
target: token.targetBase,
|
|
937
|
+
resolved: true,
|
|
938
|
+
resolvedTitle: resolved.note.title,
|
|
939
|
+
resolvedPermalink: resolved.note.permalink,
|
|
940
|
+
resolvedFilePath: resolved.note.relativePath,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
raw: token.raw,
|
|
945
|
+
target: token.targetBase,
|
|
946
|
+
resolved: false,
|
|
947
|
+
reason: resolved.reason ?? resolved.status,
|
|
948
|
+
};
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
linkHealthFromResolutions(resolutions) {
|
|
952
|
+
const total = resolutions.length;
|
|
953
|
+
const unresolved = resolutions.filter((item) => !item.resolved).length;
|
|
954
|
+
return {
|
|
955
|
+
total,
|
|
956
|
+
resolved: total - unresolved,
|
|
957
|
+
unresolved,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
unresolvedTargetsFromResolutions(resolutions) {
|
|
961
|
+
const unresolved = new Set();
|
|
962
|
+
for (const item of resolutions) {
|
|
963
|
+
if (!item.resolved) {
|
|
964
|
+
unresolved.add(item.target || item.raw);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return Array.from(unresolved.values()).sort((left, right) => left.localeCompare(right, "ja"));
|
|
968
|
+
}
|
|
969
|
+
linkSuggestionsForTarget(target, notes, limit) {
|
|
970
|
+
const candidates = [];
|
|
971
|
+
const normalizedTargetPath = target.replace(/\\/g, "/").replace(/\.md$/i, "").trim();
|
|
972
|
+
const targetDir = normalizedTargetPath.includes("/") ? path.posix.dirname(normalizedTargetPath) : "";
|
|
973
|
+
const targetBase = path.posix.basename(normalizedTargetPath);
|
|
974
|
+
const targetBaseNormalized = normalizeHintKey(targetBase);
|
|
975
|
+
for (const note of notes) {
|
|
976
|
+
let identifierScore = 0;
|
|
977
|
+
let fuzzyScore = 0;
|
|
978
|
+
for (const key of this.identifierKeys(note)) {
|
|
979
|
+
identifierScore = Math.max(identifierScore, identifierMatchScore(key, target));
|
|
980
|
+
fuzzyScore = Math.max(fuzzyScore, fuzzyIdentifierScore(key, target));
|
|
981
|
+
}
|
|
982
|
+
let score = Math.max(identifierScore, fuzzyScore);
|
|
983
|
+
if (score <= 0) {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const reasons = [];
|
|
987
|
+
if (identifierScore > 0) {
|
|
988
|
+
reasons.push("identifier_match");
|
|
989
|
+
}
|
|
990
|
+
if (fuzzyScore > 0) {
|
|
991
|
+
reasons.push("edit_distance");
|
|
992
|
+
}
|
|
993
|
+
const noteDir = path.posix.dirname(note.relativePath);
|
|
994
|
+
if (targetDir.length > 0 && targetDir !== ".") {
|
|
995
|
+
if (noteDir === targetDir) {
|
|
996
|
+
score += 60;
|
|
997
|
+
reasons.push("same_directory");
|
|
998
|
+
}
|
|
999
|
+
else if (noteDir.startsWith(`${targetDir}/`)) {
|
|
1000
|
+
score += 35;
|
|
1001
|
+
reasons.push("near_directory");
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (targetBaseNormalized.length > 0) {
|
|
1005
|
+
const noteBase = path.posix.basename(note.relativePath, MARKDOWN_EXTENSION);
|
|
1006
|
+
const noteBaseNormalized = normalizeHintKey(noteBase);
|
|
1007
|
+
if (noteBaseNormalized === targetBaseNormalized) {
|
|
1008
|
+
score += 50;
|
|
1009
|
+
reasons.push("basename_exact");
|
|
1010
|
+
}
|
|
1011
|
+
else if (noteBaseNormalized.includes(targetBaseNormalized)
|
|
1012
|
+
|| targetBaseNormalized.includes(noteBaseNormalized)) {
|
|
1013
|
+
score += 20;
|
|
1014
|
+
reasons.push("basename_partial");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
candidates.push({
|
|
1018
|
+
note,
|
|
1019
|
+
score,
|
|
1020
|
+
reasons: Array.from(new Set(reasons)),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
candidates.sort((left, right) => {
|
|
1024
|
+
if (right.score !== left.score) {
|
|
1025
|
+
return right.score - left.score;
|
|
1026
|
+
}
|
|
1027
|
+
if (right.note.updated !== left.note.updated) {
|
|
1028
|
+
return right.note.updated.localeCompare(left.note.updated);
|
|
1029
|
+
}
|
|
1030
|
+
return left.note.title.localeCompare(right.note.title, "ja");
|
|
1031
|
+
});
|
|
1032
|
+
return candidates.slice(0, limit).map((item) => ({
|
|
1033
|
+
title: item.note.title,
|
|
1034
|
+
permalink: item.note.permalink,
|
|
1035
|
+
filePath: item.note.relativePath,
|
|
1036
|
+
score: item.score,
|
|
1037
|
+
reasons: item.reasons,
|
|
1038
|
+
}));
|
|
1039
|
+
}
|
|
1040
|
+
unresolvedHintsFromResolutions(resolutions, notes) {
|
|
1041
|
+
return this.unresolvedTargetsFromResolutions(resolutions).map((target) => ({
|
|
1042
|
+
target,
|
|
1043
|
+
suggestions: this.linkSuggestionsForTarget(target, notes, 3),
|
|
1044
|
+
}));
|
|
1045
|
+
}
|
|
1046
|
+
duplicateWarningsForNote(note, notes) {
|
|
1047
|
+
const byTitle = notes.filter((item) => item.filePath !== note.filePath && item.title === note.title);
|
|
1048
|
+
const byPermalink = notes.filter((item) => item.filePath !== note.filePath && item.permalink === note.permalink);
|
|
1049
|
+
const warnings = [];
|
|
1050
|
+
if (byTitle.length > 0) {
|
|
1051
|
+
warnings.push({
|
|
1052
|
+
kind: "title",
|
|
1053
|
+
value: note.title,
|
|
1054
|
+
count: byTitle.length,
|
|
1055
|
+
examples: byTitle.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
if (byPermalink.length > 0) {
|
|
1059
|
+
warnings.push({
|
|
1060
|
+
kind: "permalink",
|
|
1061
|
+
value: note.permalink,
|
|
1062
|
+
count: byPermalink.length,
|
|
1063
|
+
examples: byPermalink.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return warnings;
|
|
1067
|
+
}
|
|
1068
|
+
backlinkCountForTarget(target, notes, lookup) {
|
|
1069
|
+
let count = 0;
|
|
1070
|
+
for (const source of notes) {
|
|
1071
|
+
if (source.filePath === target.filePath) {
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const links = this.resolveWikiLinksForText(source.body, lookup);
|
|
1075
|
+
count += links.filter((link) => link.resolved && link.resolvedFilePath === target.relativePath).length;
|
|
1076
|
+
}
|
|
1077
|
+
return count;
|
|
1078
|
+
}
|
|
1079
|
+
memoryContractSpec() {
|
|
1080
|
+
return {
|
|
1081
|
+
version: MEMORY_CONTRACT_VERSION,
|
|
1082
|
+
requiredFrontmatter: [...MEMORY_CONTRACT_REQUIRED_FRONTMATTER],
|
|
1083
|
+
flowRequiredFrontmatter: [...MEMORY_CONTRACT_FLOW_REQUIRED_FRONTMATTER],
|
|
1084
|
+
requirements: {
|
|
1085
|
+
minResolvedLinks: MEMORY_CONTRACT_REQUIREMENTS.minResolvedLinks,
|
|
1086
|
+
minBacklinks: MEMORY_CONTRACT_REQUIREMENTS.minBacklinks,
|
|
1087
|
+
minParentLinks: MEMORY_CONTRACT_REQUIREMENTS.minParentLinks,
|
|
1088
|
+
minRelatedLinks: MEMORY_CONTRACT_REQUIREMENTS.minRelatedLinks,
|
|
1089
|
+
maxUnresolvedLinks: MEMORY_CONTRACT_REQUIREMENTS.maxUnresolvedLinks,
|
|
1090
|
+
minTags: MEMORY_CONTRACT_REQUIREMENTS.minTags,
|
|
1091
|
+
},
|
|
1092
|
+
flowRequirements: {
|
|
1093
|
+
minResolvedLinks: MEMORY_CONTRACT_FLOW_REQUIREMENTS.minResolvedLinks,
|
|
1094
|
+
minBacklinks: MEMORY_CONTRACT_FLOW_REQUIREMENTS.minBacklinks,
|
|
1095
|
+
minParentLinks: MEMORY_CONTRACT_FLOW_REQUIREMENTS.minParentLinks,
|
|
1096
|
+
minRelatedLinks: MEMORY_CONTRACT_FLOW_REQUIREMENTS.minRelatedLinks,
|
|
1097
|
+
maxUnresolvedLinks: MEMORY_CONTRACT_FLOW_REQUIREMENTS.maxUnresolvedLinks,
|
|
1098
|
+
minTags: MEMORY_CONTRACT_FLOW_REQUIREMENTS.minTags,
|
|
1099
|
+
},
|
|
1100
|
+
parentLinkMarkers: [...MEMORY_CONTRACT_PARENT_MARKERS],
|
|
1101
|
+
optionalSourceMetadata: {
|
|
1102
|
+
canonicalField: "source_ref",
|
|
1103
|
+
auxiliaryFields: ["source_id", "source_root_ref"],
|
|
1104
|
+
rules: [
|
|
1105
|
+
"source metadata は任意であり必須 frontmatter ではない",
|
|
1106
|
+
"source_ref を canonical とし、source_id は補助キーとして扱う",
|
|
1107
|
+
"absolute path は保存しない",
|
|
1108
|
+
"source metadata があっても本文は source 非依存で読める状態を優先する",
|
|
1109
|
+
],
|
|
1110
|
+
},
|
|
1111
|
+
indexNavigationPolicy: {
|
|
1112
|
+
excludedMemoryKinds: [...INDEX_NAVIGATION_POLICY.excludedMemoryKinds],
|
|
1113
|
+
signals: INDEX_NAVIGATION_POLICY.signals.map((signal) => ({ ...signal })),
|
|
1114
|
+
thresholds: {
|
|
1115
|
+
scoreAtLeast: this.config.indexNavigation.scoreThreshold,
|
|
1116
|
+
indexSectionsAtLeast: this.config.indexNavigation.sectionThreshold,
|
|
1117
|
+
},
|
|
1118
|
+
configurableVia: {
|
|
1119
|
+
env: [...INDEX_NAVIGATION_POLICY.configurableVia.env],
|
|
1120
|
+
cli: [...INDEX_NAVIGATION_POLICY.configurableVia.cli],
|
|
1121
|
+
},
|
|
1122
|
+
rules: [...INDEX_NAVIGATION_POLICY.rules],
|
|
1123
|
+
},
|
|
1124
|
+
technicalDebtPolicy: {
|
|
1125
|
+
signals: [...TECHNICAL_DEBT_POLICY.signals],
|
|
1126
|
+
staleFlowStates: Array.from(STALE_FLOW_STATES),
|
|
1127
|
+
thresholds: {
|
|
1128
|
+
staleFlowDays: this.config.technicalDebt.staleFlowDays,
|
|
1129
|
+
},
|
|
1130
|
+
staleFlowAgeBuckets: {
|
|
1131
|
+
nearThreshold: TECHNICAL_DEBT_POLICY.staleFlowAgeBuckets.nearThreshold,
|
|
1132
|
+
aging: TECHNICAL_DEBT_POLICY.staleFlowAgeBuckets.aging,
|
|
1133
|
+
longStale: TECHNICAL_DEBT_POLICY.staleFlowAgeBuckets.longStale,
|
|
1134
|
+
},
|
|
1135
|
+
cleanupRatioDefinition: TECHNICAL_DEBT_POLICY.cleanupRatioDefinition,
|
|
1136
|
+
cleanupReadyCountDefinition: TECHNICAL_DEBT_POLICY.cleanupReadyCountDefinition,
|
|
1137
|
+
attentionNoteCountDefinition: TECHNICAL_DEBT_POLICY.attentionNoteCountDefinition,
|
|
1138
|
+
dependencyDirectionViolationCountDefinition: TECHNICAL_DEBT_POLICY.dependencyDirectionViolationCountDefinition,
|
|
1139
|
+
singleUseTagCandidateCountDefinition: TECHNICAL_DEBT_POLICY.singleUseTagCandidateCountDefinition,
|
|
1140
|
+
titleBodyMismatchCandidateCountDefinition: TECHNICAL_DEBT_POLICY.titleBodyMismatchCandidateCountDefinition,
|
|
1141
|
+
unresolvedWikilinksDeltaStatus: TECHNICAL_DEBT_POLICY.unresolvedWikilinksDeltaStatus,
|
|
1142
|
+
configurableVia: {
|
|
1143
|
+
env: [...TECHNICAL_DEBT_POLICY.configurableVia.env],
|
|
1144
|
+
cli: [...TECHNICAL_DEBT_POLICY.configurableVia.cli],
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
responseModePolicy: {
|
|
1148
|
+
supportedTools: {
|
|
1149
|
+
read: [...RESPONSE_MODE_POLICY.supportedTools.read],
|
|
1150
|
+
audit: [...RESPONSE_MODE_POLICY.supportedTools.audit],
|
|
1151
|
+
},
|
|
1152
|
+
modes: {
|
|
1153
|
+
minimal: RESPONSE_MODE_POLICY.modes.minimal,
|
|
1154
|
+
standard: RESPONSE_MODE_POLICY.modes.standard,
|
|
1155
|
+
verbose: RESPONSE_MODE_POLICY.modes.verbose,
|
|
1156
|
+
},
|
|
1157
|
+
rules: [...RESPONSE_MODE_POLICY.rules],
|
|
1158
|
+
},
|
|
1159
|
+
sampleTemplates: {
|
|
1160
|
+
stock: [
|
|
1161
|
+
"---",
|
|
1162
|
+
"type: note",
|
|
1163
|
+
"tags:",
|
|
1164
|
+
" - topic",
|
|
1165
|
+
"created: <YYYY-MM-DD>",
|
|
1166
|
+
"updated: <YYYY-MM-DD>",
|
|
1167
|
+
"permalink: <slug>",
|
|
1168
|
+
"---",
|
|
1169
|
+
"- 親: [[上位ノート]]",
|
|
1170
|
+
"- 関連: [[関連ノートA]]",
|
|
1171
|
+
"- 関連: [[関連ノートB]]",
|
|
1172
|
+
"",
|
|
1173
|
+
"## 要点",
|
|
1174
|
+
"",
|
|
1175
|
+
"- 何を残すノートか",
|
|
1176
|
+
"",
|
|
1177
|
+
"## 詳細",
|
|
1178
|
+
"",
|
|
1179
|
+
"- 再利用したい知識を書く",
|
|
1180
|
+
],
|
|
1181
|
+
flow: [
|
|
1182
|
+
"---",
|
|
1183
|
+
"type: flow",
|
|
1184
|
+
"tags:",
|
|
1185
|
+
" - topic",
|
|
1186
|
+
"created: <YYYY-MM-DD>",
|
|
1187
|
+
"updated: <YYYY-MM-DD>",
|
|
1188
|
+
"permalink: <slug>",
|
|
1189
|
+
"memory_kind: flow",
|
|
1190
|
+
"flow_state: active",
|
|
1191
|
+
"question: <いま答えたい問い>",
|
|
1192
|
+
"next_action: <次にやる具体行動>",
|
|
1193
|
+
"parent_stock: <既存 stock の title or permalink>",
|
|
1194
|
+
"---",
|
|
1195
|
+
"- 親: [[親テーマ or MOC]]",
|
|
1196
|
+
"- 関連: [[関連Flow or Stock]]",
|
|
1197
|
+
"",
|
|
1198
|
+
"## Notes",
|
|
1199
|
+
"",
|
|
1200
|
+
"- 判断材料、障害、進捗を書く",
|
|
1201
|
+
],
|
|
1202
|
+
index: [
|
|
1203
|
+
"---",
|
|
1204
|
+
"type: note",
|
|
1205
|
+
"tags:",
|
|
1206
|
+
" - index",
|
|
1207
|
+
"created: <YYYY-MM-DD>",
|
|
1208
|
+
"updated: <YYYY-MM-DD>",
|
|
1209
|
+
"permalink: <slug>",
|
|
1210
|
+
"---",
|
|
1211
|
+
"- 親: [[上位計画 or MOC]]",
|
|
1212
|
+
"- 関連: [[関連Flow or 監査ノート]]",
|
|
1213
|
+
"",
|
|
1214
|
+
"## 目的",
|
|
1215
|
+
"",
|
|
1216
|
+
"- 何の入口かを書く",
|
|
1217
|
+
"",
|
|
1218
|
+
"## テーマ別入口",
|
|
1219
|
+
"",
|
|
1220
|
+
"- [[代表ノートA]]",
|
|
1221
|
+
"- [[代表ノートB]]",
|
|
1222
|
+
"",
|
|
1223
|
+
"## まず何を読むか",
|
|
1224
|
+
"",
|
|
1225
|
+
"1. [[最初に読むノート]]",
|
|
1226
|
+
],
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
contractLinkSignals(note, lookup) {
|
|
1231
|
+
const parentTargets = new Set();
|
|
1232
|
+
const relatedTargets = new Set();
|
|
1233
|
+
const tokens = extractWikiLinks(note.body);
|
|
1234
|
+
for (const token of tokens) {
|
|
1235
|
+
const resolved = this.resolveWikiTarget(token.targetBase, lookup);
|
|
1236
|
+
if (resolved.status !== "resolved" || !resolved.note) {
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
const linePrefix = linePrefixAtOffset(note.body, token.start);
|
|
1240
|
+
const target = resolved.note.relativePath;
|
|
1241
|
+
if (PARENT_LINE_PATTERN.test(linePrefix)) {
|
|
1242
|
+
parentTargets.add(target);
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
relatedTargets.add(target);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return {
|
|
1249
|
+
parentLinks: parentTargets.size,
|
|
1250
|
+
relatedLinks: relatedTargets.size,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
parentLinkTargets(note, lookup) {
|
|
1254
|
+
const targets = new Set();
|
|
1255
|
+
const tokens = extractWikiLinks(note.body);
|
|
1256
|
+
for (const token of tokens) {
|
|
1257
|
+
const linePrefix = linePrefixAtOffset(note.body, token.start);
|
|
1258
|
+
if (!PARENT_LINE_PATTERN.test(linePrefix)) {
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const resolved = this.resolveWikiTarget(token.targetBase, lookup);
|
|
1262
|
+
if (resolved.status === "resolved" && resolved.note) {
|
|
1263
|
+
targets.add(resolved.note.relativePath);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return targets;
|
|
1267
|
+
}
|
|
1268
|
+
resolvedLinkTargets(note, lookup) {
|
|
1269
|
+
const targets = new Set();
|
|
1270
|
+
const resolutions = this.resolveWikiLinksForText(note.body, lookup);
|
|
1271
|
+
for (const resolution of resolutions) {
|
|
1272
|
+
if (!resolution.resolved || !resolution.resolvedFilePath) {
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
targets.add(resolution.resolvedFilePath);
|
|
1276
|
+
}
|
|
1277
|
+
return targets;
|
|
1278
|
+
}
|
|
1279
|
+
indexSignalsForNote(note) {
|
|
1280
|
+
if (this.memoryKindForNote(note) === "flow") {
|
|
1281
|
+
return { score: 0, reasons: [], indexLike: false };
|
|
1282
|
+
}
|
|
1283
|
+
let score = 0;
|
|
1284
|
+
const reasons = [];
|
|
1285
|
+
const normalizedTags = new Set(safeStringArray(note.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
1286
|
+
if (INDEX_TAG_MARKERS.some((tag) => normalizedTags.has(tag))) {
|
|
1287
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "index_tag").weight;
|
|
1288
|
+
reasons.push("index_tag");
|
|
1289
|
+
}
|
|
1290
|
+
if (INDEX_TITLE_PATTERN.test(note.title)) {
|
|
1291
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "title_marker").weight;
|
|
1292
|
+
reasons.push("title_marker");
|
|
1293
|
+
}
|
|
1294
|
+
if (INDEX_TITLE_PATTERN.test(note.permalink)) {
|
|
1295
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "permalink_marker").weight;
|
|
1296
|
+
reasons.push("permalink_marker");
|
|
1297
|
+
}
|
|
1298
|
+
if (INDEX_TITLE_PATTERN.test(note.relativePath)) {
|
|
1299
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "path_marker").weight;
|
|
1300
|
+
reasons.push("path_marker");
|
|
1301
|
+
}
|
|
1302
|
+
let sectionCount = 0;
|
|
1303
|
+
for (const heading of INDEX_SECTION_HEADINGS) {
|
|
1304
|
+
if (extractSectionBody(note.body, heading).length > 0) {
|
|
1305
|
+
sectionCount += 1;
|
|
1306
|
+
reasons.push(`section:${heading}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
if (sectionCount > 0) {
|
|
1310
|
+
score += sectionCount * INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "section:*").weight;
|
|
1311
|
+
}
|
|
1312
|
+
if (sectionCount >= INDEX_NAVIGATION_POLICY.thresholds.indexSectionsAtLeast) {
|
|
1313
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "multi_index_sections").weight;
|
|
1314
|
+
reasons.push("multi_index_sections");
|
|
1315
|
+
}
|
|
1316
|
+
const wikiLinkCount = extractWikiLinks(note.body).length;
|
|
1317
|
+
if (wikiLinkCount >= 4) {
|
|
1318
|
+
score += INDEX_NAVIGATION_POLICY.signals.find((signal) => signal.code === "link_hub").weight;
|
|
1319
|
+
reasons.push("link_hub");
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
score,
|
|
1323
|
+
reasons: uniqueStrings(reasons),
|
|
1324
|
+
indexLike: score >= this.config.indexNavigation.scoreThreshold ||
|
|
1325
|
+
sectionCount >= this.config.indexNavigation.sectionThreshold,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
indexMetaForNote(note) {
|
|
1329
|
+
const signals = this.indexSignalsForNote(note);
|
|
1330
|
+
return {
|
|
1331
|
+
indexLike: signals.indexLike,
|
|
1332
|
+
indexReasons: signals.indexLike ? signals.reasons : [],
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
finalizeIndexCandidates(candidates, limit) {
|
|
1336
|
+
candidates.sort((left, right) => {
|
|
1337
|
+
if (right.score !== left.score) {
|
|
1338
|
+
return right.score - left.score;
|
|
1339
|
+
}
|
|
1340
|
+
if (right.note.updated !== left.note.updated) {
|
|
1341
|
+
return right.note.updated.localeCompare(left.note.updated);
|
|
1342
|
+
}
|
|
1343
|
+
return left.note.title.localeCompare(right.note.title, "ja");
|
|
1344
|
+
});
|
|
1345
|
+
return candidates.slice(0, limit).map((item) => ({
|
|
1346
|
+
title: item.note.title,
|
|
1347
|
+
permalink: item.note.permalink,
|
|
1348
|
+
filePath: item.note.relativePath,
|
|
1349
|
+
score: item.score,
|
|
1350
|
+
reasons: item.reasons,
|
|
1351
|
+
}));
|
|
1352
|
+
}
|
|
1353
|
+
recentIndexCandidates(notes, directoryPrefix, limit) {
|
|
1354
|
+
const candidates = [];
|
|
1355
|
+
for (const note of notes) {
|
|
1356
|
+
if (directoryPrefix.length > 0) {
|
|
1357
|
+
const matchPrefix = `${directoryPrefix}/`;
|
|
1358
|
+
if (!note.relativePath.startsWith(matchPrefix)) {
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
const signals = this.indexSignalsForNote(note);
|
|
1363
|
+
if (!signals.indexLike) {
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
candidates.push({
|
|
1367
|
+
note,
|
|
1368
|
+
score: signals.score,
|
|
1369
|
+
reasons: signals.reasons,
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
return this.finalizeIndexCandidates(candidates, limit);
|
|
1373
|
+
}
|
|
1374
|
+
searchIndexCandidates(notes, query, afterDate, limit) {
|
|
1375
|
+
const candidates = [];
|
|
1376
|
+
for (const note of notes) {
|
|
1377
|
+
if (afterDate && note.updated < afterDate) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const signals = this.indexSignalsForNote(note);
|
|
1381
|
+
if (!signals.indexLike) {
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
const queryScore = this.noteScore(note, query);
|
|
1385
|
+
if (queryScore <= 0) {
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
candidates.push({
|
|
1389
|
+
note,
|
|
1390
|
+
score: signals.score + queryScore,
|
|
1391
|
+
reasons: uniqueStrings([...signals.reasons, "query_match"]),
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
return this.finalizeIndexCandidates(candidates, limit);
|
|
1395
|
+
}
|
|
1396
|
+
relatedIndexCandidates(sourceNote, notes, limit) {
|
|
1397
|
+
const lookup = this.buildLookup(notes);
|
|
1398
|
+
const sourceOutgoing = this.resolvedLinkTargets(sourceNote, lookup);
|
|
1399
|
+
const sourceParents = this.parentLinkTargets(sourceNote, lookup);
|
|
1400
|
+
const sourceTags = new Set(safeStringArray(sourceNote.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
1401
|
+
const sourceDir = path.posix.dirname(sourceNote.relativePath);
|
|
1402
|
+
const candidates = [];
|
|
1403
|
+
for (const candidate of notes) {
|
|
1404
|
+
if (candidate.filePath === sourceNote.filePath) {
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
const signals = this.indexSignalsForNote(candidate);
|
|
1408
|
+
if (!signals.indexLike) {
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
const reasons = [...signals.reasons];
|
|
1412
|
+
let score = signals.score;
|
|
1413
|
+
const candidateOutgoing = this.resolvedLinkTargets(candidate, lookup);
|
|
1414
|
+
if (sourceParents.has(candidate.relativePath)) {
|
|
1415
|
+
score += 180;
|
|
1416
|
+
reasons.push("source_parent_index");
|
|
1417
|
+
}
|
|
1418
|
+
if (sourceOutgoing.has(candidate.relativePath)) {
|
|
1419
|
+
score += 110;
|
|
1420
|
+
reasons.push("source_links_index");
|
|
1421
|
+
}
|
|
1422
|
+
if (candidateOutgoing.has(sourceNote.relativePath)) {
|
|
1423
|
+
score += 160;
|
|
1424
|
+
reasons.push("index_links_source");
|
|
1425
|
+
}
|
|
1426
|
+
const candidateTags = new Set(safeStringArray(candidate.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
1427
|
+
const sharedTags = intersectCount(sourceTags, candidateTags);
|
|
1428
|
+
if (sharedTags > 0) {
|
|
1429
|
+
score += Math.min(60, sharedTags * 20);
|
|
1430
|
+
reasons.push(`shared_tags:${sharedTags}`);
|
|
1431
|
+
}
|
|
1432
|
+
const candidateDir = path.posix.dirname(candidate.relativePath);
|
|
1433
|
+
if (candidateDir === sourceDir) {
|
|
1434
|
+
score += 20;
|
|
1435
|
+
reasons.push("same_directory");
|
|
1436
|
+
}
|
|
1437
|
+
candidates.push({
|
|
1438
|
+
note: candidate,
|
|
1439
|
+
score,
|
|
1440
|
+
reasons: uniqueStrings(reasons),
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
return this.finalizeIndexCandidates(candidates, limit);
|
|
1444
|
+
}
|
|
1445
|
+
explorationDirectoryHint(notes) {
|
|
1446
|
+
if (notes.length < 2) {
|
|
1447
|
+
return undefined;
|
|
1448
|
+
}
|
|
1449
|
+
const exactCounts = new Map();
|
|
1450
|
+
const topLevelCounts = new Map();
|
|
1451
|
+
for (const note of notes) {
|
|
1452
|
+
const directory = path.posix.dirname(note.relativePath);
|
|
1453
|
+
if (directory !== ".") {
|
|
1454
|
+
exactCounts.set(directory, (exactCounts.get(directory) ?? 0) + 1);
|
|
1455
|
+
}
|
|
1456
|
+
const topLevel = note.relativePath.split("/")[0]?.trim();
|
|
1457
|
+
if (topLevel) {
|
|
1458
|
+
topLevelCounts.set(topLevel, (topLevelCounts.get(topLevel) ?? 0) + 1);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
const bestExact = [...exactCounts.entries()]
|
|
1462
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "ja"))[0];
|
|
1463
|
+
if (bestExact && bestExact[1] >= 2) {
|
|
1464
|
+
return {
|
|
1465
|
+
directory: bestExact[0],
|
|
1466
|
+
suggestedTool: "recent_notes",
|
|
1467
|
+
reason: `clustered_directory:${bestExact[0]}(${bestExact[1]}/${notes.length})`,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
const bestTopLevel = [...topLevelCounts.entries()]
|
|
1471
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "ja"))[0];
|
|
1472
|
+
if (bestTopLevel && bestTopLevel[1] >= 2 && bestTopLevel[0] !== ".") {
|
|
1473
|
+
return {
|
|
1474
|
+
directory: bestTopLevel[0],
|
|
1475
|
+
suggestedTool: "recent_notes",
|
|
1476
|
+
reason: `clustered_root_directory:${bestTopLevel[0]}(${bestTopLevel[1]}/${notes.length})`,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
return undefined;
|
|
1480
|
+
}
|
|
1481
|
+
explorationTagPresets(allNotes, candidateNotes, baseQuery, reasonPrefix) {
|
|
1482
|
+
if (candidateNotes.length < 2) {
|
|
1483
|
+
return [];
|
|
1484
|
+
}
|
|
1485
|
+
const excludedTerms = new Set(searchTerms(baseQuery).map((term) => normalizeLookup(term)));
|
|
1486
|
+
const globalCounts = new Map();
|
|
1487
|
+
const labels = new Map();
|
|
1488
|
+
for (const note of allNotes) {
|
|
1489
|
+
const noteTags = new Set();
|
|
1490
|
+
for (const tag of safeStringArray(note.frontmatter.tags)) {
|
|
1491
|
+
const normalized = normalizeLookup(tag);
|
|
1492
|
+
if (!normalized || EXPLORATION_TAG_STOPWORDS.has(normalized) || normalized.startsWith("batch")) {
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
noteTags.add(normalized);
|
|
1496
|
+
if (!labels.has(normalized)) {
|
|
1497
|
+
labels.set(normalized, tag);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
noteTags.forEach((tag) => {
|
|
1501
|
+
globalCounts.set(tag, (globalCounts.get(tag) ?? 0) + 1);
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
const localCounts = new Map();
|
|
1505
|
+
for (const note of candidateNotes) {
|
|
1506
|
+
const noteTags = new Set();
|
|
1507
|
+
for (const tag of safeStringArray(note.frontmatter.tags)) {
|
|
1508
|
+
const normalized = normalizeLookup(tag);
|
|
1509
|
+
if (!normalized || excludedTerms.has(normalized) || EXPLORATION_TAG_STOPWORDS.has(normalized) || normalized.startsWith("batch")) {
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
noteTags.add(normalized);
|
|
1513
|
+
if (!labels.has(normalized)) {
|
|
1514
|
+
labels.set(normalized, tag);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
noteTags.forEach((tag) => {
|
|
1518
|
+
localCounts.set(tag, (localCounts.get(tag) ?? 0) + 1);
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
return [...localCounts.entries()]
|
|
1522
|
+
.filter(([, localCount]) => localCount >= 2)
|
|
1523
|
+
.map(([tag, localCount]) => ({
|
|
1524
|
+
tag,
|
|
1525
|
+
label: labels.get(tag) ?? tag,
|
|
1526
|
+
localCount,
|
|
1527
|
+
globalCount: globalCounts.get(tag) ?? localCount,
|
|
1528
|
+
}))
|
|
1529
|
+
.filter((item) => item.globalCount <= item.localCount * 4)
|
|
1530
|
+
.sort((left, right) => right.localCount - left.localCount || left.globalCount - right.globalCount || left.label.localeCompare(right.label, "ja"))
|
|
1531
|
+
.slice(0, 2)
|
|
1532
|
+
.map((item) => ({
|
|
1533
|
+
query: `${baseQuery} ${item.label}`.trim(),
|
|
1534
|
+
reason: `${reasonPrefix}:${item.label}(${item.localCount}/${item.globalCount})`,
|
|
1535
|
+
}));
|
|
1536
|
+
}
|
|
1537
|
+
searchExplorationGuidance(allNotes, rankedNotes, query) {
|
|
1538
|
+
const topNotes = rankedNotes.slice(0, 5);
|
|
1539
|
+
return {
|
|
1540
|
+
directoryScopeHint: this.explorationDirectoryHint(topNotes),
|
|
1541
|
+
queryPresets: this.explorationTagPresets(allNotes, topNotes, query, "shared_result_tag"),
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
contextExplorationGuidance(allNotes, sourceNote, rankedNotes) {
|
|
1545
|
+
const topNotes = rankedNotes.slice(0, 5);
|
|
1546
|
+
return {
|
|
1547
|
+
directoryScopeHint: this.explorationDirectoryHint([sourceNote, ...topNotes]),
|
|
1548
|
+
queryPresets: this.explorationTagPresets(allNotes, [sourceNote, ...topNotes], sourceNote.title, "shared_context_tag"),
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
indexNavigationForNote(note, notes, limit) {
|
|
1552
|
+
const meta = this.indexMetaForNote(note);
|
|
1553
|
+
return {
|
|
1554
|
+
indexLike: meta.indexLike,
|
|
1555
|
+
indexReasons: meta.indexReasons,
|
|
1556
|
+
indexNoteCandidates: this.relatedIndexCandidates(note, notes, limit),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
memoryKindForNote(note) {
|
|
1560
|
+
const explicit = safeString(note.frontmatter.memory_kind)?.toLowerCase();
|
|
1561
|
+
if (explicit === "flow") {
|
|
1562
|
+
return "flow";
|
|
1563
|
+
}
|
|
1564
|
+
if (explicit === "stock") {
|
|
1565
|
+
return "stock";
|
|
1566
|
+
}
|
|
1567
|
+
if (safeString(note.frontmatter.flow_state)) {
|
|
1568
|
+
return "flow";
|
|
1569
|
+
}
|
|
1570
|
+
if (/^flow(\/|$)/i.test(note.relativePath)) {
|
|
1571
|
+
return "flow";
|
|
1572
|
+
}
|
|
1573
|
+
return "stock";
|
|
1574
|
+
}
|
|
1575
|
+
boundaryWarningsForNote(note) {
|
|
1576
|
+
if (this.memoryKindForNote(note) === "flow") {
|
|
1577
|
+
const indexBoundaryWarning = this.indexBoundaryWarningForFlow(note);
|
|
1578
|
+
return indexBoundaryWarning ? [indexBoundaryWarning] : [];
|
|
1579
|
+
}
|
|
1580
|
+
const typeLooksFlow = safeString(note.frontmatter.type)?.toLowerCase() === "flow";
|
|
1581
|
+
const hasFlowFrontmatter = Boolean(safeString(note.frontmatter.flow_state))
|
|
1582
|
+
|| Boolean(safeString(note.frontmatter.parent_stock))
|
|
1583
|
+
|| Boolean(safeString(note.frontmatter.question))
|
|
1584
|
+
|| Boolean(safeString(note.frontmatter.next_action));
|
|
1585
|
+
const hasFlowSections = FLOW_QUESTION_HEADING_PATTERN.test(note.body)
|
|
1586
|
+
&& FLOW_NEXT_ACTION_HEADING_PATTERN.test(note.body)
|
|
1587
|
+
&& FLOW_PARENT_LINE_PATTERN.test(note.body);
|
|
1588
|
+
if (!typeLooksFlow && !hasFlowFrontmatter && !hasFlowSections) {
|
|
1589
|
+
return [];
|
|
1590
|
+
}
|
|
1591
|
+
return [
|
|
1592
|
+
{
|
|
1593
|
+
code: "flow_like_content_in_generic_note",
|
|
1594
|
+
message: "flow 形の内容が generic note として保存されている",
|
|
1595
|
+
suggestion: "進行中の問いなら write_flow_note に移し、確定知識は stock ノートへ分離する",
|
|
1596
|
+
},
|
|
1597
|
+
];
|
|
1598
|
+
}
|
|
1599
|
+
indexBoundaryWarningForFlow(note) {
|
|
1600
|
+
const normalizedTags = new Set(safeStringArray(note.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
1601
|
+
const hasIndexTag = INDEX_TAG_MARKERS.some((tag) => normalizedTags.has(tag));
|
|
1602
|
+
const hasTitleMarker = INDEX_TITLE_PATTERN.test(note.title) ||
|
|
1603
|
+
INDEX_TITLE_PATTERN.test(note.permalink) ||
|
|
1604
|
+
INDEX_TITLE_PATTERN.test(note.relativePath);
|
|
1605
|
+
let sectionCount = 0;
|
|
1606
|
+
for (const heading of INDEX_SECTION_HEADINGS) {
|
|
1607
|
+
if (extractSectionBody(note.body, heading).length > 0) {
|
|
1608
|
+
sectionCount += 1;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const wikiLinkCount = extractWikiLinks(note.body).length;
|
|
1612
|
+
const looksIndexLike = hasIndexTag ||
|
|
1613
|
+
sectionCount >= this.config.indexNavigation.sectionThreshold ||
|
|
1614
|
+
(hasTitleMarker && sectionCount >= 1 && wikiLinkCount >= 4);
|
|
1615
|
+
if (!looksIndexLike) {
|
|
1616
|
+
return undefined;
|
|
1617
|
+
}
|
|
1618
|
+
return {
|
|
1619
|
+
code: "index_like_content_in_flow_note",
|
|
1620
|
+
message: "index/MOC 形の入口ノートが flow として保存されている",
|
|
1621
|
+
suggestion: "再訪入口なら stock/index ノートへ移し、未完了の問いと next_action だけを flow に残す",
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
isStaleFlow(note, referenceDate = nowDate()) {
|
|
1625
|
+
const staleDays = this.staleFlowAgeDays(note, referenceDate);
|
|
1626
|
+
return staleDays !== undefined && staleDays >= this.config.technicalDebt.staleFlowDays;
|
|
1627
|
+
}
|
|
1628
|
+
staleFlowAgeDays(note, referenceDate = nowDate()) {
|
|
1629
|
+
if (this.memoryKindForNote(note) !== "flow") {
|
|
1630
|
+
return undefined;
|
|
1631
|
+
}
|
|
1632
|
+
const flowState = safeString(note.frontmatter.flow_state)?.toLowerCase();
|
|
1633
|
+
if (!flowState || !STALE_FLOW_STATES.has(flowState)) {
|
|
1634
|
+
return undefined;
|
|
1635
|
+
}
|
|
1636
|
+
return daysBetween(note.updated, referenceDate);
|
|
1637
|
+
}
|
|
1638
|
+
staleFlowAgeBucket(staleDays) {
|
|
1639
|
+
const threshold = this.config.technicalDebt.staleFlowDays;
|
|
1640
|
+
if (staleDays >= threshold + 23) {
|
|
1641
|
+
return "longStale";
|
|
1642
|
+
}
|
|
1643
|
+
if (staleDays >= threshold + 7) {
|
|
1644
|
+
return "aging";
|
|
1645
|
+
}
|
|
1646
|
+
return "nearThreshold";
|
|
1647
|
+
}
|
|
1648
|
+
isSingleUseTagCandidateIgnored(normalizedTag) {
|
|
1649
|
+
if (SINGLE_USE_TAG_EXACT_IGNORES.has(normalizedTag)) {
|
|
1650
|
+
return true;
|
|
1651
|
+
}
|
|
1652
|
+
return SINGLE_USE_TAG_PATTERN_IGNORES.some((pattern) => pattern.test(normalizedTag));
|
|
1653
|
+
}
|
|
1654
|
+
singleUseTagCandidates(notes) {
|
|
1655
|
+
const usage = new Map();
|
|
1656
|
+
for (const note of notes) {
|
|
1657
|
+
if (this.memoryKindForNote(note) === "flow") {
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
const seenInNote = new Set();
|
|
1661
|
+
for (const tag of safeStringArray(note.frontmatter.tags)) {
|
|
1662
|
+
const normalizedTag = normalizeLookup(tag);
|
|
1663
|
+
if (!normalizedTag || seenInNote.has(normalizedTag) || this.isSingleUseTagCandidateIgnored(normalizedTag)) {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
seenInNote.add(normalizedTag);
|
|
1667
|
+
const existing = usage.get(normalizedTag);
|
|
1668
|
+
if (existing) {
|
|
1669
|
+
existing.count += 1;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
usage.set(normalizedTag, {
|
|
1673
|
+
tag,
|
|
1674
|
+
note,
|
|
1675
|
+
count: 1,
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
const candidatesByFilePath = new Map();
|
|
1680
|
+
for (const candidate of usage.values()) {
|
|
1681
|
+
if (candidate.count !== 1) {
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
const existing = candidatesByFilePath.get(candidate.note.filePath);
|
|
1685
|
+
if (existing) {
|
|
1686
|
+
existing.tags.push(candidate.tag);
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
candidatesByFilePath.set(candidate.note.filePath, {
|
|
1690
|
+
note: candidate.note,
|
|
1691
|
+
tags: [candidate.tag],
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
return Array.from(candidatesByFilePath.values())
|
|
1695
|
+
.filter((candidate) => candidate.tags.length >= 2)
|
|
1696
|
+
.sort((left, right) => {
|
|
1697
|
+
const byCount = right.tags.length - left.tags.length;
|
|
1698
|
+
if (byCount !== 0) {
|
|
1699
|
+
return byCount;
|
|
1700
|
+
}
|
|
1701
|
+
return left.note.title.localeCompare(right.note.title, "ja");
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
titleBodyMismatchCandidates(notes) {
|
|
1705
|
+
const candidates = [];
|
|
1706
|
+
for (const note of notes) {
|
|
1707
|
+
if (this.memoryKindForNote(note) === "flow") {
|
|
1708
|
+
continue;
|
|
1709
|
+
}
|
|
1710
|
+
const titleTerms = titleBodySemanticTerms(note.title);
|
|
1711
|
+
if (titleTerms.length < 2) {
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
const headingTerms = markdownHeadings(note.body).flatMap((heading) => titleBodySemanticTerms(heading));
|
|
1715
|
+
const bodyTerms = titleBodySemanticTerms(excerptBody(note.body, 6, 420));
|
|
1716
|
+
const contentTerms = new Set([...headingTerms, ...bodyTerms]);
|
|
1717
|
+
if (contentTerms.size === 0) {
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
const overlap = titleTerms.filter((term) => contentTerms.has(term));
|
|
1721
|
+
if (overlap.length > 0) {
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
candidates.push({
|
|
1725
|
+
note,
|
|
1726
|
+
titleTerms: titleTerms.slice(0, 3),
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
return candidates.sort((left, right) => left.note.title.localeCompare(right.note.title, "ja"));
|
|
1730
|
+
}
|
|
1731
|
+
dependencyDirectionViolations(notes, lookup) {
|
|
1732
|
+
const noteByRelativePath = new Map(notes.map((note) => [note.relativePath, note]));
|
|
1733
|
+
const candidates = [];
|
|
1734
|
+
for (const note of notes) {
|
|
1735
|
+
if (this.memoryKindForNote(note) !== "stock") {
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
const parentFlowTargets = [...this.parentLinkTargets(note, lookup)]
|
|
1739
|
+
.map((relativePath) => noteByRelativePath.get(relativePath))
|
|
1740
|
+
.filter((candidate) => Boolean(candidate))
|
|
1741
|
+
.filter((candidate) => this.memoryKindForNote(candidate) === "flow");
|
|
1742
|
+
if (parentFlowTargets.length === 0) {
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
candidates.push({
|
|
1746
|
+
note,
|
|
1747
|
+
parentFlowTargets,
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
return candidates.sort((left, right) => left.note.title.localeCompare(right.note.title, "ja"));
|
|
1751
|
+
}
|
|
1752
|
+
orphanRepairQueueItem(note, notes) {
|
|
1753
|
+
const indexCandidates = this.relatedIndexCandidates(note, notes, 1);
|
|
1754
|
+
const suggestedParent = indexCandidates[0];
|
|
1755
|
+
if (suggestedParent) {
|
|
1756
|
+
return {
|
|
1757
|
+
noteTitle: note.title,
|
|
1758
|
+
notePath: note.relativePath,
|
|
1759
|
+
category: "index_parent_available",
|
|
1760
|
+
impact: "medium",
|
|
1761
|
+
suggestedParent: {
|
|
1762
|
+
title: suggestedParent.title,
|
|
1763
|
+
permalink: suggestedParent.permalink,
|
|
1764
|
+
filePath: suggestedParent.filePath,
|
|
1765
|
+
reasons: suggestedParent.reasons,
|
|
1766
|
+
},
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
return {
|
|
1770
|
+
noteTitle: note.title,
|
|
1771
|
+
notePath: note.relativePath,
|
|
1772
|
+
category: "manual_linking_needed",
|
|
1773
|
+
impact: "medium",
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
memoryContractForNote(args) {
|
|
1777
|
+
const spec = this.memoryContractSpec();
|
|
1778
|
+
const violations = [];
|
|
1779
|
+
const memoryKind = this.memoryKindForNote(args.note);
|
|
1780
|
+
const requirements = memoryKind === "flow" ? spec.flowRequirements : spec.requirements;
|
|
1781
|
+
const hasFrontmatterValue = (key) => {
|
|
1782
|
+
if (!(key in args.note.frontmatter)) {
|
|
1783
|
+
return false;
|
|
1784
|
+
}
|
|
1785
|
+
const value = args.note.frontmatter[key];
|
|
1786
|
+
if (Array.isArray(value)) {
|
|
1787
|
+
return value.length > 0;
|
|
1788
|
+
}
|
|
1789
|
+
return value.trim().length > 0;
|
|
1790
|
+
};
|
|
1791
|
+
const missingFrontmatter = spec.requiredFrontmatter.filter((key) => !hasFrontmatterValue(key));
|
|
1792
|
+
for (const key of missingFrontmatter) {
|
|
1793
|
+
violations.push({
|
|
1794
|
+
code: "missing_frontmatter_key",
|
|
1795
|
+
message: `frontmatter key が不足: ${key}`,
|
|
1796
|
+
suggestion: `frontmatter に ${key} を追加する`,
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
if (memoryKind === "flow") {
|
|
1800
|
+
for (const key of spec.flowRequiredFrontmatter) {
|
|
1801
|
+
if (hasFrontmatterValue(key)) {
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
violations.push({
|
|
1805
|
+
code: "missing_flow_field",
|
|
1806
|
+
message: `flow 用 frontmatter が不足: ${key}`,
|
|
1807
|
+
suggestion: `frontmatter に ${key} を追加する`,
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
const tagsCount = safeStringArray(args.note.frontmatter.tags).length;
|
|
1812
|
+
if (tagsCount < requirements.minTags) {
|
|
1813
|
+
violations.push({
|
|
1814
|
+
code: "missing_tags",
|
|
1815
|
+
message: `tags が不足: ${tagsCount}件`,
|
|
1816
|
+
suggestion: `frontmatter の tags を ${requirements.minTags} 件以上にする`,
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
if (args.linkHealth.unresolved > requirements.maxUnresolvedLinks) {
|
|
1820
|
+
violations.push({
|
|
1821
|
+
code: "too_many_unresolved_links",
|
|
1822
|
+
message: `未解決リンクが多い: ${args.linkHealth.unresolved}件`,
|
|
1823
|
+
suggestion: "未解決 WikiLink を修正する",
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (args.linkHealth.resolved < requirements.minResolvedLinks) {
|
|
1827
|
+
violations.push({
|
|
1828
|
+
code: "insufficient_resolved_links",
|
|
1829
|
+
message: `解決済みリンクが不足: ${args.linkHealth.resolved}件`,
|
|
1830
|
+
suggestion: `解決済み WikiLink を ${requirements.minResolvedLinks} 件以上にする`,
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
if (args.backlinkCount < requirements.minBacklinks) {
|
|
1834
|
+
violations.push({
|
|
1835
|
+
code: "insufficient_backlinks",
|
|
1836
|
+
message: `バックリンクが不足: ${args.backlinkCount}件`,
|
|
1837
|
+
suggestion: `関連ノートからの被リンクを ${requirements.minBacklinks} 件以上にする`,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
const linkSignals = this.contractLinkSignals(args.note, args.lookup);
|
|
1841
|
+
if (linkSignals.parentLinks < requirements.minParentLinks) {
|
|
1842
|
+
violations.push({
|
|
1843
|
+
code: "missing_parent_link",
|
|
1844
|
+
message: `親リンクが不足: ${linkSignals.parentLinks}件`,
|
|
1845
|
+
suggestion: "本文に `- 親: [[...]]` 形式の上位リンクを追加する",
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
if (linkSignals.relatedLinks < requirements.minRelatedLinks) {
|
|
1849
|
+
violations.push({
|
|
1850
|
+
code: "missing_related_link",
|
|
1851
|
+
message: `関連リンクが不足: ${linkSignals.relatedLinks}件`,
|
|
1852
|
+
suggestion: "本文に `- 関連: [[...]]` 形式の関連リンクを追加する",
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
if (memoryKind === "flow") {
|
|
1856
|
+
const flowState = safeString(args.note.frontmatter.flow_state)?.toLowerCase();
|
|
1857
|
+
if (flowState && !FLOW_STATES.has(flowState)) {
|
|
1858
|
+
violations.push({
|
|
1859
|
+
code: "missing_flow_field",
|
|
1860
|
+
message: `flow_state が不正: ${flowState}`,
|
|
1861
|
+
suggestion: `flow_state を ${Array.from(FLOW_STATES).join(" / ")} のいずれかにする`,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
const parentStock = safeString(args.note.frontmatter.parent_stock);
|
|
1865
|
+
if (parentStock) {
|
|
1866
|
+
const parentResolved = this.resolveWikiTarget(parentStock, args.lookup);
|
|
1867
|
+
if (parentResolved.status !== "resolved" || !parentResolved.note) {
|
|
1868
|
+
violations.push({
|
|
1869
|
+
code: "invalid_parent_stock",
|
|
1870
|
+
message: `parent_stock が解決できない: ${parentStock}`,
|
|
1871
|
+
suggestion: "parent_stock を既存の stock ノートに更新する",
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
else if (this.memoryKindForNote(parentResolved.note) !== "stock") {
|
|
1875
|
+
violations.push({
|
|
1876
|
+
code: "invalid_parent_stock",
|
|
1877
|
+
message: `parent_stock が stock 以外を指している: ${parentResolved.note.title}`,
|
|
1878
|
+
suggestion: "parent_stock を stock ノートへ修正する",
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return {
|
|
1884
|
+
version: spec.version,
|
|
1885
|
+
status: violations.length === 0 ? "pass" : "fail",
|
|
1886
|
+
signals: {
|
|
1887
|
+
resolvedLinks: args.linkHealth.resolved,
|
|
1888
|
+
unresolvedLinks: args.linkHealth.unresolved,
|
|
1889
|
+
backlinks: args.backlinkCount,
|
|
1890
|
+
parentLinks: linkSignals.parentLinks,
|
|
1891
|
+
relatedLinks: linkSignals.relatedLinks,
|
|
1892
|
+
tags: tagsCount,
|
|
1893
|
+
},
|
|
1894
|
+
violations,
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
getMemoryContract() {
|
|
1898
|
+
return this.memoryContractSpec();
|
|
1899
|
+
}
|
|
1900
|
+
structureScoreForNote(args) {
|
|
1901
|
+
let score = 0;
|
|
1902
|
+
const nextActions = [];
|
|
1903
|
+
if (args.linkHealth.resolved > 0) {
|
|
1904
|
+
score += 20;
|
|
1905
|
+
}
|
|
1906
|
+
else {
|
|
1907
|
+
nextActions.push("既存ノートへの WikiLink を1件以上追加する");
|
|
1908
|
+
}
|
|
1909
|
+
if (args.backlinkCount > 0) {
|
|
1910
|
+
score += 20;
|
|
1911
|
+
}
|
|
1912
|
+
else {
|
|
1913
|
+
nextActions.push("関連ノートからこのノートへの WikiLink を追加する");
|
|
1914
|
+
}
|
|
1915
|
+
if (args.unresolvedWikiLinks.length === 0) {
|
|
1916
|
+
score += 25;
|
|
1917
|
+
}
|
|
1918
|
+
else {
|
|
1919
|
+
const sample = args.unresolvedWikiLinks.slice(0, 2).join(", ");
|
|
1920
|
+
nextActions.push(`未解決 WikiLink を修正する: ${sample}`);
|
|
1921
|
+
}
|
|
1922
|
+
if (args.duplicateWarnings.length === 0) {
|
|
1923
|
+
score += 15;
|
|
1924
|
+
}
|
|
1925
|
+
else {
|
|
1926
|
+
nextActions.push("title/permalink の重複を解消する");
|
|
1927
|
+
}
|
|
1928
|
+
if (safeString(args.note.frontmatter.type)) {
|
|
1929
|
+
score += 10;
|
|
1930
|
+
}
|
|
1931
|
+
else {
|
|
1932
|
+
nextActions.push("frontmatter に type を追加する");
|
|
1933
|
+
}
|
|
1934
|
+
if (safeStringArray(args.note.frontmatter.tags).length > 0) {
|
|
1935
|
+
score += 10;
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
nextActions.push("frontmatter に tags を1件以上追加する");
|
|
1939
|
+
}
|
|
1940
|
+
let grade = "D";
|
|
1941
|
+
if (score >= 85) {
|
|
1942
|
+
grade = "A";
|
|
1943
|
+
}
|
|
1944
|
+
else if (score >= 70) {
|
|
1945
|
+
grade = "B";
|
|
1946
|
+
}
|
|
1947
|
+
else if (score >= 50) {
|
|
1948
|
+
grade = "C";
|
|
1949
|
+
}
|
|
1950
|
+
return {
|
|
1951
|
+
score,
|
|
1952
|
+
grade,
|
|
1953
|
+
nextActions: nextActions.slice(0, 4),
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
documentClarityForNote(note) {
|
|
1957
|
+
const titlePresence = note.title.trim().length > 0;
|
|
1958
|
+
const summaryPresence = summaryHeadingPresent(note.body);
|
|
1959
|
+
const contentLines = clarityContentLines(note.body);
|
|
1960
|
+
const listRatio = listRatioForMarkdown(note.body);
|
|
1961
|
+
const longSentenceCount = longSentenceCountForMarkdown(note.body);
|
|
1962
|
+
const attentionReasons = [];
|
|
1963
|
+
if (!summaryPresence) {
|
|
1964
|
+
attentionReasons.push("summary_missing");
|
|
1965
|
+
}
|
|
1966
|
+
if (contentLines.length >= CLARITY_MIN_CONTENT_LINES_FOR_LIST_CHECK && listRatio < CLARITY_LOW_LIST_RATIO_THRESHOLD) {
|
|
1967
|
+
attentionReasons.push("low_list_ratio");
|
|
1968
|
+
}
|
|
1969
|
+
if (longSentenceCount >= CLARITY_LONG_SENTENCE_ATTENTION_COUNT) {
|
|
1970
|
+
attentionReasons.push("long_sentences_present");
|
|
1971
|
+
}
|
|
1972
|
+
if (!titlePresence) {
|
|
1973
|
+
attentionReasons.push("title_missing");
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
status: attentionReasons.length >= 2 ? "attention" : "clear",
|
|
1977
|
+
titlePresence,
|
|
1978
|
+
summaryPresence,
|
|
1979
|
+
listRatio,
|
|
1980
|
+
longSentenceCount,
|
|
1981
|
+
attentionReasons,
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
qualitySignalsForNote(note, notes) {
|
|
1985
|
+
const lookup = this.buildLookup(notes);
|
|
1986
|
+
const resolutions = this.resolveWikiLinksForText(note.body, lookup);
|
|
1987
|
+
const linkHealth = this.linkHealthFromResolutions(resolutions);
|
|
1988
|
+
const unresolvedWikiLinks = this.unresolvedTargetsFromResolutions(resolutions);
|
|
1989
|
+
const unresolvedLinkHints = this.unresolvedHintsFromResolutions(resolutions, notes);
|
|
1990
|
+
const duplicateWarnings = this.duplicateWarningsForNote(note, notes);
|
|
1991
|
+
const backlinkCount = this.backlinkCountForTarget(note, notes, lookup);
|
|
1992
|
+
const orphanWarning = backlinkCount === 0 && linkHealth.resolved === 0;
|
|
1993
|
+
const structureScore = this.structureScoreForNote({
|
|
1994
|
+
note,
|
|
1995
|
+
linkHealth,
|
|
1996
|
+
unresolvedWikiLinks,
|
|
1997
|
+
duplicateWarnings,
|
|
1998
|
+
backlinkCount,
|
|
1999
|
+
});
|
|
2000
|
+
const memoryContract = this.memoryContractForNote({
|
|
2001
|
+
note,
|
|
2002
|
+
linkHealth,
|
|
2003
|
+
backlinkCount,
|
|
2004
|
+
lookup,
|
|
2005
|
+
});
|
|
2006
|
+
const boundaryWarnings = this.boundaryWarningsForNote(note);
|
|
2007
|
+
const documentClarity = this.documentClarityForNote(note);
|
|
2008
|
+
return {
|
|
2009
|
+
linkHealth,
|
|
2010
|
+
unresolvedWikiLinks,
|
|
2011
|
+
unresolvedLinkHints,
|
|
2012
|
+
duplicateWarnings,
|
|
2013
|
+
backlinkCount,
|
|
2014
|
+
orphanWarning,
|
|
2015
|
+
structureScore,
|
|
2016
|
+
memoryContract,
|
|
2017
|
+
boundaryWarnings,
|
|
2018
|
+
documentClarity,
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
projectLinkHealth(notes) {
|
|
2022
|
+
const lookup = this.buildLookup(notes);
|
|
2023
|
+
let total = 0;
|
|
2024
|
+
let resolved = 0;
|
|
2025
|
+
for (const note of notes) {
|
|
2026
|
+
const health = this.linkHealthFromResolutions(this.resolveWikiLinksForText(note.body, lookup));
|
|
2027
|
+
total += health.total;
|
|
2028
|
+
resolved += health.resolved;
|
|
2029
|
+
}
|
|
2030
|
+
return {
|
|
2031
|
+
total,
|
|
2032
|
+
resolved,
|
|
2033
|
+
unresolved: total - resolved,
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
projectUnresolvedSignals(notes) {
|
|
2037
|
+
const lookup = this.buildLookup(notes);
|
|
2038
|
+
const resolutions = [];
|
|
2039
|
+
for (const note of notes) {
|
|
2040
|
+
resolutions.push(...this.resolveWikiLinksForText(note.body, lookup));
|
|
2041
|
+
}
|
|
2042
|
+
const unresolvedWikiLinks = this.unresolvedTargetsFromResolutions(resolutions).slice(0, MAX_SIGNAL_ITEMS);
|
|
2043
|
+
const unresolvedLinkHints = this.unresolvedHintsFromResolutions(resolutions, notes)
|
|
2044
|
+
.filter((item) => unresolvedWikiLinks.includes(item.target))
|
|
2045
|
+
.slice(0, MAX_SIGNAL_ITEMS);
|
|
2046
|
+
return {
|
|
2047
|
+
unresolvedWikiLinks,
|
|
2048
|
+
unresolvedLinkHints,
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
async loadProjectNotes() {
|
|
2052
|
+
const files = await this.storage.listMarkdownFiles();
|
|
2053
|
+
return Promise.all(files.map((relativePath) => this.loadNote(relativePath)));
|
|
2054
|
+
}
|
|
2055
|
+
async loadNote(relativePath) {
|
|
2056
|
+
const raw = await this.storage.readFile(relativePath);
|
|
2057
|
+
const fileStat = await this.storage.stat(relativePath);
|
|
2058
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
2059
|
+
const filePath = path.resolve(this.root, relativePath);
|
|
2060
|
+
const title = safeString(frontmatter.title) ?? path.basename(relativePath, MARKDOWN_EXTENSION);
|
|
2061
|
+
const created = safeString(frontmatter.created) ?? (fileStat.birthtime ? formatLocalDate(fileStat.birthtime) : nowDate());
|
|
2062
|
+
const updated = safeString(frontmatter.updated) ?? (fileStat.mtime ? formatLocalDate(fileStat.mtime) : nowDate());
|
|
2063
|
+
const defaultPermalink = relativePath.endsWith(MARKDOWN_EXTENSION)
|
|
2064
|
+
? relativePath.slice(0, -MARKDOWN_EXTENSION.length)
|
|
2065
|
+
: relativePath;
|
|
2066
|
+
const permalink = safeString(frontmatter.permalink) ?? defaultPermalink;
|
|
2067
|
+
return {
|
|
2068
|
+
title,
|
|
2069
|
+
permalink,
|
|
2070
|
+
filePath,
|
|
2071
|
+
relativePath,
|
|
2072
|
+
frontmatter,
|
|
2073
|
+
body,
|
|
2074
|
+
raw,
|
|
2075
|
+
created,
|
|
2076
|
+
updated,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
async resolveIdentifier(identifier) {
|
|
2080
|
+
const trimmed = identifier.trim();
|
|
2081
|
+
if (trimmed.length === 0) {
|
|
2082
|
+
return undefined;
|
|
2083
|
+
}
|
|
2084
|
+
const pathCandidates = [trimmed, trimmed.endsWith(MARKDOWN_EXTENSION) ? undefined : `${trimmed}${MARKDOWN_EXTENSION}`]
|
|
2085
|
+
.filter((candidate) => Boolean(candidate))
|
|
2086
|
+
.map((candidate) => sanitizeRelativePath(candidate));
|
|
2087
|
+
for (const candidate of pathCandidates) {
|
|
2088
|
+
const absolutePath = path.resolve(this.root, candidate);
|
|
2089
|
+
if (!isInsideDirectory(this.root, absolutePath)) {
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
const candidateStat = await this.storage.stat(candidate);
|
|
2093
|
+
if (candidateStat.exists && candidateStat.isFile) {
|
|
2094
|
+
return await this.loadNote(candidate);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const notes = await this.loadProjectNotes();
|
|
2098
|
+
const normalizedInput = trimmed.toLowerCase();
|
|
2099
|
+
const fuzzyMatches = [];
|
|
2100
|
+
for (const note of notes) {
|
|
2101
|
+
const keys = this.identifierKeys(note);
|
|
2102
|
+
if (keys.some((key) => key.toLowerCase() === normalizedInput)) {
|
|
2103
|
+
return note;
|
|
2104
|
+
}
|
|
2105
|
+
let bestScore = 0;
|
|
2106
|
+
for (const key of keys) {
|
|
2107
|
+
bestScore = Math.max(bestScore, identifierMatchScore(key, trimmed));
|
|
2108
|
+
}
|
|
2109
|
+
if (bestScore > 0) {
|
|
2110
|
+
fuzzyMatches.push({ note, score: bestScore });
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (fuzzyMatches.length === 0) {
|
|
2114
|
+
return undefined;
|
|
2115
|
+
}
|
|
2116
|
+
fuzzyMatches.sort((left, right) => {
|
|
2117
|
+
if (right.score !== left.score) {
|
|
2118
|
+
return right.score - left.score;
|
|
2119
|
+
}
|
|
2120
|
+
return right.note.updated.localeCompare(left.note.updated);
|
|
2121
|
+
});
|
|
2122
|
+
if (fuzzyMatches.length === 1) {
|
|
2123
|
+
return fuzzyMatches[0].note;
|
|
2124
|
+
}
|
|
2125
|
+
if (fuzzyMatches[0].score >= fuzzyMatches[1].score + 20) {
|
|
2126
|
+
return fuzzyMatches[0].note;
|
|
2127
|
+
}
|
|
2128
|
+
return undefined;
|
|
2129
|
+
}
|
|
2130
|
+
noteScore(note, query) {
|
|
2131
|
+
const normalizedQuery = normalizeForSearch(query);
|
|
2132
|
+
const terms = searchTerms(query);
|
|
2133
|
+
if (normalizedQuery.length === 0 || terms.length === 0) {
|
|
2134
|
+
return 0;
|
|
2135
|
+
}
|
|
2136
|
+
const queryCompact = compactSearchText(normalizedQuery);
|
|
2137
|
+
const title = normalizeForSearch(note.title);
|
|
2138
|
+
const permalink = normalizeForSearch(note.permalink);
|
|
2139
|
+
const body = normalizeForSearch(note.body);
|
|
2140
|
+
const titleCompact = compactSearchText(title);
|
|
2141
|
+
const permalinkCompact = compactSearchText(permalink);
|
|
2142
|
+
const bodyCompact = compactSearchText(body);
|
|
2143
|
+
let score = 0;
|
|
2144
|
+
if (this.hasExactIdentifierMatch(note, query)) {
|
|
2145
|
+
score += 10_000;
|
|
2146
|
+
}
|
|
2147
|
+
score += countOccurrences(title, normalizedQuery) * 36;
|
|
2148
|
+
score += countOccurrences(permalink, normalizedQuery) * 22;
|
|
2149
|
+
score += countOccurrences(body, normalizedQuery) * 5;
|
|
2150
|
+
if (queryCompact.length >= 2) {
|
|
2151
|
+
score += countOccurrences(titleCompact, queryCompact) * 28;
|
|
2152
|
+
score += countOccurrences(permalinkCompact, queryCompact) * 16;
|
|
2153
|
+
score += countOccurrences(bodyCompact, queryCompact) * 3;
|
|
2154
|
+
}
|
|
2155
|
+
for (const term of terms) {
|
|
2156
|
+
const length = stringLength(term);
|
|
2157
|
+
const likelyNgram = isCjkToken(term) && length <= 3;
|
|
2158
|
+
const titleWeight = likelyNgram
|
|
2159
|
+
? 6
|
|
2160
|
+
: length >= 4
|
|
2161
|
+
? 18
|
|
2162
|
+
: 12;
|
|
2163
|
+
const permalinkWeight = likelyNgram
|
|
2164
|
+
? 3
|
|
2165
|
+
: length >= 4
|
|
2166
|
+
? 10
|
|
2167
|
+
: 6;
|
|
2168
|
+
const bodyWeight = likelyNgram ? 1 : 2;
|
|
2169
|
+
score += countOccurrences(title, term) * titleWeight;
|
|
2170
|
+
score += countOccurrences(permalink, term) * permalinkWeight;
|
|
2171
|
+
score += countOccurrences(body, term) * bodyWeight;
|
|
2172
|
+
}
|
|
2173
|
+
score += Math.floor(identifierMatchScore(note.title, query) / 25);
|
|
2174
|
+
score += Math.floor(identifierMatchScore(note.permalink, query) / 35);
|
|
2175
|
+
return score;
|
|
2176
|
+
}
|
|
2177
|
+
hasExactIdentifierMatch(note, query) {
|
|
2178
|
+
const trimmed = query.trim();
|
|
2179
|
+
if (trimmed.length === 0) {
|
|
2180
|
+
return false;
|
|
2181
|
+
}
|
|
2182
|
+
const normalizedInput = trimmed.toLowerCase();
|
|
2183
|
+
const normalizedLookupInput = normalizeLookup(trimmed);
|
|
2184
|
+
return this.identifierKeys(note).some((key) => {
|
|
2185
|
+
if (key.toLowerCase() === normalizedInput) {
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
return normalizedLookupInput.length > 0 && normalizeLookup(key) === normalizedLookupInput;
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
async searchNotes(args) {
|
|
2192
|
+
const notes = await this.loadProjectNotes();
|
|
2193
|
+
const results = [];
|
|
2194
|
+
for (const note of notes) {
|
|
2195
|
+
if (args.afterDate && note.updated < args.afterDate) {
|
|
2196
|
+
continue;
|
|
2197
|
+
}
|
|
2198
|
+
const score = this.noteScore(note, args.query);
|
|
2199
|
+
if (score <= 0) {
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
const exactIdentifierMatch = this.hasExactIdentifierMatch(note, args.query);
|
|
2203
|
+
results.push({
|
|
2204
|
+
title: note.title,
|
|
2205
|
+
permalink: note.permalink,
|
|
2206
|
+
filePath: note.relativePath,
|
|
2207
|
+
content: note.body,
|
|
2208
|
+
score,
|
|
2209
|
+
updated: note.updated,
|
|
2210
|
+
exactIdentifierMatch,
|
|
2211
|
+
...this.indexMetaForNote(note),
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
results.sort((left, right) => {
|
|
2215
|
+
if (left.exactIdentifierMatch !== right.exactIdentifierMatch) {
|
|
2216
|
+
return left.exactIdentifierMatch ? -1 : 1;
|
|
2217
|
+
}
|
|
2218
|
+
if (right.score !== left.score) {
|
|
2219
|
+
return right.score - left.score;
|
|
2220
|
+
}
|
|
2221
|
+
return right.updated.localeCompare(left.updated);
|
|
2222
|
+
});
|
|
2223
|
+
const rankedNotes = results
|
|
2224
|
+
.map((item) => notes.find((note) => note.relativePath === item.filePath))
|
|
2225
|
+
.filter((note) => Boolean(note));
|
|
2226
|
+
return {
|
|
2227
|
+
project: this.projectName,
|
|
2228
|
+
results: results.slice(0, args.limit),
|
|
2229
|
+
exactIdentifierMatchCount: results.filter((item) => item.exactIdentifierMatch).length,
|
|
2230
|
+
indexNoteCandidates: this.searchIndexCandidates(notes, args.query, args.afterDate, 3),
|
|
2231
|
+
explorationGuidance: this.searchExplorationGuidance(notes, rankedNotes, args.query),
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
async recentNotes(args) {
|
|
2235
|
+
const notes = await this.loadProjectNotes();
|
|
2236
|
+
const directoryPrefix = sanitizeRelativePath(args.directory);
|
|
2237
|
+
const results = [];
|
|
2238
|
+
for (const note of notes) {
|
|
2239
|
+
if (directoryPrefix.length > 0) {
|
|
2240
|
+
const matchPrefix = `${directoryPrefix}/`;
|
|
2241
|
+
if (!note.relativePath.startsWith(matchPrefix)) {
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
results.push({
|
|
2246
|
+
title: note.title,
|
|
2247
|
+
permalink: note.permalink,
|
|
2248
|
+
filePath: note.relativePath,
|
|
2249
|
+
updated: note.updated,
|
|
2250
|
+
...this.indexMetaForNote(note),
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
results.sort((left, right) => right.updated.localeCompare(left.updated));
|
|
2254
|
+
return {
|
|
2255
|
+
project: this.projectName,
|
|
2256
|
+
results: results.slice(0, args.limit),
|
|
2257
|
+
indexNoteCandidates: this.recentIndexCandidates(notes, directoryPrefix, 3),
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
async suggestNotes(args) {
|
|
2261
|
+
const query = args.query.trim();
|
|
2262
|
+
if (query.length === 0) {
|
|
2263
|
+
return { project: this.projectName, results: [] };
|
|
2264
|
+
}
|
|
2265
|
+
const notes = await this.loadProjectNotes();
|
|
2266
|
+
const results = [];
|
|
2267
|
+
for (const note of notes) {
|
|
2268
|
+
let keyScore = 0;
|
|
2269
|
+
for (const key of this.identifierKeys(note)) {
|
|
2270
|
+
keyScore = Math.max(keyScore, identifierMatchScore(key, query));
|
|
2271
|
+
}
|
|
2272
|
+
const bodyScore = this.noteScore(note, query);
|
|
2273
|
+
const score = keyScore + bodyScore;
|
|
2274
|
+
if (score <= 0) {
|
|
2275
|
+
continue;
|
|
2276
|
+
}
|
|
2277
|
+
const exactIdentifierMatch = this.hasExactIdentifierMatch(note, query);
|
|
2278
|
+
results.push({
|
|
2279
|
+
title: note.title,
|
|
2280
|
+
permalink: note.permalink,
|
|
2281
|
+
filePath: note.relativePath,
|
|
2282
|
+
content: note.body,
|
|
2283
|
+
score,
|
|
2284
|
+
updated: note.updated,
|
|
2285
|
+
exactIdentifierMatch,
|
|
2286
|
+
...this.indexMetaForNote(note),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
results.sort((left, right) => {
|
|
2290
|
+
if (left.exactIdentifierMatch !== right.exactIdentifierMatch) {
|
|
2291
|
+
return left.exactIdentifierMatch ? -1 : 1;
|
|
2292
|
+
}
|
|
2293
|
+
if (right.score !== left.score) {
|
|
2294
|
+
return right.score - left.score;
|
|
2295
|
+
}
|
|
2296
|
+
return right.updated.localeCompare(left.updated);
|
|
2297
|
+
});
|
|
2298
|
+
return {
|
|
2299
|
+
project: this.projectName,
|
|
2300
|
+
results: results.slice(0, args.limit),
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
async readNote(identifier) {
|
|
2304
|
+
const note = await this.resolveIdentifier(identifier);
|
|
2305
|
+
if (!note) {
|
|
2306
|
+
throw new Error(`note not found: ${identifier}`);
|
|
2307
|
+
}
|
|
2308
|
+
const notes = await this.loadProjectNotes();
|
|
2309
|
+
const currentNote = notes.find((item) => item.filePath === note.filePath) ?? note;
|
|
2310
|
+
const signals = this.qualitySignalsForNote(currentNote, notes);
|
|
2311
|
+
const indexNavigation = this.indexNavigationForNote(currentNote, notes, 3);
|
|
2312
|
+
return {
|
|
2313
|
+
project: this.projectName,
|
|
2314
|
+
note: currentNote,
|
|
2315
|
+
linkHealth: signals.linkHealth,
|
|
2316
|
+
unresolvedWikiLinks: signals.unresolvedWikiLinks,
|
|
2317
|
+
unresolvedLinkHints: signals.unresolvedLinkHints,
|
|
2318
|
+
backlinkCount: signals.backlinkCount,
|
|
2319
|
+
orphanWarning: signals.orphanWarning,
|
|
2320
|
+
boundaryWarnings: signals.boundaryWarnings,
|
|
2321
|
+
documentClarity: signals.documentClarity,
|
|
2322
|
+
indexLike: indexNavigation.indexLike,
|
|
2323
|
+
indexReasons: indexNavigation.indexReasons,
|
|
2324
|
+
indexNoteCandidates: indexNavigation.indexNoteCandidates,
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
async resolveLinks(args) {
|
|
2328
|
+
const note = await this.resolveIdentifier(args.identifier);
|
|
2329
|
+
if (!note) {
|
|
2330
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
2331
|
+
}
|
|
2332
|
+
const notes = await this.loadProjectNotes();
|
|
2333
|
+
const lookup = this.buildLookup(notes);
|
|
2334
|
+
const links = this.resolveWikiLinksForText(note.body, lookup);
|
|
2335
|
+
const indexNavigation = this.indexNavigationForNote(note, notes, 3);
|
|
2336
|
+
return {
|
|
2337
|
+
project: this.projectName,
|
|
2338
|
+
note,
|
|
2339
|
+
links,
|
|
2340
|
+
boundaryWarnings: this.boundaryWarningsForNote(note),
|
|
2341
|
+
indexLike: indexNavigation.indexLike,
|
|
2342
|
+
indexReasons: indexNavigation.indexReasons,
|
|
2343
|
+
indexNoteCandidates: indexNavigation.indexNoteCandidates,
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
async listBacklinks(args) {
|
|
2347
|
+
const target = await this.resolveIdentifier(args.identifier);
|
|
2348
|
+
if (!target) {
|
|
2349
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
2350
|
+
}
|
|
2351
|
+
const notes = await this.loadProjectNotes();
|
|
2352
|
+
const lookup = this.buildLookup(notes);
|
|
2353
|
+
const indexNavigation = this.indexNavigationForNote(target, notes, 3);
|
|
2354
|
+
const backlinks = [];
|
|
2355
|
+
for (const source of notes) {
|
|
2356
|
+
const links = this.resolveWikiLinksForText(source.body, lookup);
|
|
2357
|
+
const matches = links.filter((link) => link.resolved && link.resolvedFilePath === target.relativePath);
|
|
2358
|
+
if (matches.length === 0) {
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
backlinks.push({
|
|
2362
|
+
title: source.title,
|
|
2363
|
+
permalink: source.permalink,
|
|
2364
|
+
filePath: source.relativePath,
|
|
2365
|
+
updated: source.updated,
|
|
2366
|
+
count: matches.length,
|
|
2367
|
+
examples: matches.slice(0, 3).map((item) => item.raw),
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
backlinks.sort((left, right) => {
|
|
2371
|
+
if (right.count !== left.count) {
|
|
2372
|
+
return right.count - left.count;
|
|
2373
|
+
}
|
|
2374
|
+
return right.updated.localeCompare(left.updated);
|
|
2375
|
+
});
|
|
2376
|
+
return {
|
|
2377
|
+
project: this.projectName,
|
|
2378
|
+
target,
|
|
2379
|
+
backlinks: backlinks.slice(0, args.limit),
|
|
2380
|
+
boundaryWarnings: this.boundaryWarningsForNote(target),
|
|
2381
|
+
indexLike: indexNavigation.indexLike,
|
|
2382
|
+
indexReasons: indexNavigation.indexReasons,
|
|
2383
|
+
indexNoteCandidates: indexNavigation.indexNoteCandidates,
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
async lintStructure(args) {
|
|
2387
|
+
const notes = await this.loadProjectNotes();
|
|
2388
|
+
if (notes.length === 0) {
|
|
2389
|
+
return {
|
|
2390
|
+
project: this.projectName,
|
|
2391
|
+
noteCount: 0,
|
|
2392
|
+
averageScore: 0,
|
|
2393
|
+
gradeCounts: { A: 0, B: 0, C: 0, D: 0 },
|
|
2394
|
+
unresolvedWikiLinkCount: 0,
|
|
2395
|
+
orphanNoteCount: 0,
|
|
2396
|
+
duplicateWarningCount: 0,
|
|
2397
|
+
weakStructureCount: 0,
|
|
2398
|
+
memoryContractViolationCount: 0,
|
|
2399
|
+
technicalDebtSignals: {
|
|
2400
|
+
unresolvedWikilinksDelta: null,
|
|
2401
|
+
unresolvedWikilinksDeltaStatus: "requires_history",
|
|
2402
|
+
staleFlowCount: 0,
|
|
2403
|
+
staleFlowDays: this.config.technicalDebt.staleFlowDays,
|
|
2404
|
+
staleFlowAgeBuckets: emptyStaleFlowAgeBuckets(),
|
|
2405
|
+
oldestStaleFlowDays: null,
|
|
2406
|
+
cleanupRatio: 1,
|
|
2407
|
+
cleanupReadyCount: 0,
|
|
2408
|
+
attentionNoteCount: 0,
|
|
2409
|
+
dependencyDirectionViolationCount: 0,
|
|
2410
|
+
singleUseTagCandidateCount: 0,
|
|
2411
|
+
titleBodyMismatchCandidateCount: 0,
|
|
2412
|
+
},
|
|
2413
|
+
orphanRepairQueue: [],
|
|
2414
|
+
issues: [],
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
const lookup = this.buildLookup(notes);
|
|
2418
|
+
const noteByRelativePath = new Map();
|
|
2419
|
+
const backlinkByFilePath = new Map();
|
|
2420
|
+
const titleToNotes = new Map();
|
|
2421
|
+
const permalinkToNotes = new Map();
|
|
2422
|
+
const resolutionsByFilePath = new Map();
|
|
2423
|
+
const pushMap = (map, key, note) => {
|
|
2424
|
+
const items = map.get(key) ?? [];
|
|
2425
|
+
items.push(note);
|
|
2426
|
+
map.set(key, items);
|
|
2427
|
+
};
|
|
2428
|
+
for (const note of notes) {
|
|
2429
|
+
noteByRelativePath.set(note.relativePath, note);
|
|
2430
|
+
backlinkByFilePath.set(note.filePath, 0);
|
|
2431
|
+
pushMap(titleToNotes, note.title, note);
|
|
2432
|
+
pushMap(permalinkToNotes, note.permalink, note);
|
|
2433
|
+
}
|
|
2434
|
+
for (const source of notes) {
|
|
2435
|
+
const resolutions = this.resolveWikiLinksForText(source.body, lookup);
|
|
2436
|
+
resolutionsByFilePath.set(source.filePath, resolutions);
|
|
2437
|
+
for (const link of resolutions) {
|
|
2438
|
+
if (!link.resolved || !link.resolvedFilePath) {
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
const target = noteByRelativePath.get(link.resolvedFilePath);
|
|
2442
|
+
if (!target) {
|
|
2443
|
+
continue;
|
|
2444
|
+
}
|
|
2445
|
+
backlinkByFilePath.set(target.filePath, (backlinkByFilePath.get(target.filePath) ?? 0) + 1);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
const issues = [];
|
|
2449
|
+
const orphanRepairQueue = [];
|
|
2450
|
+
const gradeCounts = {
|
|
2451
|
+
A: 0,
|
|
2452
|
+
B: 0,
|
|
2453
|
+
C: 0,
|
|
2454
|
+
D: 0,
|
|
2455
|
+
};
|
|
2456
|
+
let unresolvedWikiLinkCount = 0;
|
|
2457
|
+
let orphanNoteCount = 0;
|
|
2458
|
+
let duplicateWarningCount = 0;
|
|
2459
|
+
let weakStructureCount = 0;
|
|
2460
|
+
let memoryContractViolationCount = 0;
|
|
2461
|
+
let staleFlowCount = 0;
|
|
2462
|
+
let oldestStaleFlowDays = null;
|
|
2463
|
+
const staleFlowAgeBuckets = emptyStaleFlowAgeBuckets();
|
|
2464
|
+
let cleanupReadyCount = 0;
|
|
2465
|
+
let totalScore = 0;
|
|
2466
|
+
let dependencyDirectionViolationCount = 0;
|
|
2467
|
+
const singleUseTagCandidates = this.singleUseTagCandidates(notes);
|
|
2468
|
+
const titleBodyMismatchCandidates = this.titleBodyMismatchCandidates(notes);
|
|
2469
|
+
const dependencyDirectionViolations = this.dependencyDirectionViolations(notes, lookup);
|
|
2470
|
+
const singleUseTagCandidateByFilePath = new Map();
|
|
2471
|
+
for (const candidate of singleUseTagCandidates) {
|
|
2472
|
+
const items = singleUseTagCandidateByFilePath.get(candidate.note.filePath) ?? [];
|
|
2473
|
+
items.push(candidate);
|
|
2474
|
+
singleUseTagCandidateByFilePath.set(candidate.note.filePath, items);
|
|
2475
|
+
}
|
|
2476
|
+
const titleBodyMismatchCandidateByFilePath = new Map();
|
|
2477
|
+
for (const candidate of titleBodyMismatchCandidates) {
|
|
2478
|
+
const items = titleBodyMismatchCandidateByFilePath.get(candidate.note.filePath) ?? [];
|
|
2479
|
+
items.push(candidate);
|
|
2480
|
+
titleBodyMismatchCandidateByFilePath.set(candidate.note.filePath, items);
|
|
2481
|
+
}
|
|
2482
|
+
const dependencyDirectionViolationByFilePath = new Map();
|
|
2483
|
+
for (const candidate of dependencyDirectionViolations) {
|
|
2484
|
+
const items = dependencyDirectionViolationByFilePath.get(candidate.note.filePath) ?? [];
|
|
2485
|
+
items.push(candidate);
|
|
2486
|
+
dependencyDirectionViolationByFilePath.set(candidate.note.filePath, items);
|
|
2487
|
+
}
|
|
2488
|
+
for (const note of notes) {
|
|
2489
|
+
const resolutions = resolutionsByFilePath.get(note.filePath) ?? [];
|
|
2490
|
+
const linkHealth = this.linkHealthFromResolutions(resolutions);
|
|
2491
|
+
const unresolvedWikiLinks = this.unresolvedTargetsFromResolutions(resolutions);
|
|
2492
|
+
unresolvedWikiLinkCount += unresolvedWikiLinks.length;
|
|
2493
|
+
const sameTitle = (titleToNotes.get(note.title) ?? []).filter((item) => item.filePath !== note.filePath);
|
|
2494
|
+
const samePermalink = (permalinkToNotes.get(note.permalink) ?? []).filter((item) => item.filePath !== note.filePath);
|
|
2495
|
+
const duplicateWarnings = [];
|
|
2496
|
+
if (sameTitle.length > 0) {
|
|
2497
|
+
duplicateWarnings.push({
|
|
2498
|
+
kind: "title",
|
|
2499
|
+
value: note.title,
|
|
2500
|
+
count: sameTitle.length,
|
|
2501
|
+
examples: sameTitle.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
if (samePermalink.length > 0) {
|
|
2505
|
+
duplicateWarnings.push({
|
|
2506
|
+
kind: "permalink",
|
|
2507
|
+
value: note.permalink,
|
|
2508
|
+
count: samePermalink.length,
|
|
2509
|
+
examples: samePermalink.slice(0, 3).map((item) => `${item.title} (${item.relativePath})`),
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
duplicateWarningCount += duplicateWarnings.length;
|
|
2513
|
+
const backlinkCount = backlinkByFilePath.get(note.filePath) ?? 0;
|
|
2514
|
+
const orphanWarning = backlinkCount === 0 && linkHealth.resolved === 0;
|
|
2515
|
+
if (orphanWarning) {
|
|
2516
|
+
orphanNoteCount += 1;
|
|
2517
|
+
orphanRepairQueue.push(this.orphanRepairQueueItem(note, notes));
|
|
2518
|
+
}
|
|
2519
|
+
const structureScore = this.structureScoreForNote({
|
|
2520
|
+
note,
|
|
2521
|
+
linkHealth,
|
|
2522
|
+
unresolvedWikiLinks,
|
|
2523
|
+
duplicateWarnings,
|
|
2524
|
+
backlinkCount,
|
|
2525
|
+
});
|
|
2526
|
+
const memoryContract = this.memoryContractForNote({
|
|
2527
|
+
note,
|
|
2528
|
+
linkHealth,
|
|
2529
|
+
backlinkCount,
|
|
2530
|
+
lookup,
|
|
2531
|
+
});
|
|
2532
|
+
totalScore += structureScore.score;
|
|
2533
|
+
gradeCounts[structureScore.grade] += 1;
|
|
2534
|
+
const weakStructure = structureScore.score < 70;
|
|
2535
|
+
if (weakStructure) {
|
|
2536
|
+
weakStructureCount += 1;
|
|
2537
|
+
}
|
|
2538
|
+
if (memoryContract.status === "fail") {
|
|
2539
|
+
memoryContractViolationCount += 1;
|
|
2540
|
+
}
|
|
2541
|
+
const staleFlowDays = this.staleFlowAgeDays(note);
|
|
2542
|
+
const staleFlow = staleFlowDays !== undefined && staleFlowDays >= this.config.technicalDebt.staleFlowDays;
|
|
2543
|
+
const singleUseTagCandidatesForNote = singleUseTagCandidateByFilePath.get(note.filePath) ?? [];
|
|
2544
|
+
const titleBodyMismatchCandidatesForNote = titleBodyMismatchCandidateByFilePath.get(note.filePath) ?? [];
|
|
2545
|
+
const dependencyDirectionViolationsForNote = dependencyDirectionViolationByFilePath.get(note.filePath) ?? [];
|
|
2546
|
+
if (staleFlow) {
|
|
2547
|
+
staleFlowCount += 1;
|
|
2548
|
+
staleFlowAgeBuckets[this.staleFlowAgeBucket(staleFlowDays)] += 1;
|
|
2549
|
+
oldestStaleFlowDays = oldestStaleFlowDays === null
|
|
2550
|
+
? staleFlowDays
|
|
2551
|
+
: Math.max(oldestStaleFlowDays, staleFlowDays);
|
|
2552
|
+
}
|
|
2553
|
+
const needsCleanup = unresolvedWikiLinks.length > 0
|
|
2554
|
+
|| orphanWarning
|
|
2555
|
+
|| duplicateWarnings.length > 0
|
|
2556
|
+
|| dependencyDirectionViolationsForNote.length > 0
|
|
2557
|
+
|| weakStructure
|
|
2558
|
+
|| memoryContract.status === "fail"
|
|
2559
|
+
|| staleFlow;
|
|
2560
|
+
if (!needsCleanup) {
|
|
2561
|
+
cleanupReadyCount += 1;
|
|
2562
|
+
}
|
|
2563
|
+
if (unresolvedWikiLinks.length > 0) {
|
|
2564
|
+
issues.push({
|
|
2565
|
+
severity: "high",
|
|
2566
|
+
category: "unresolved_wikilink",
|
|
2567
|
+
noteTitle: note.title,
|
|
2568
|
+
notePath: note.relativePath,
|
|
2569
|
+
message: `未解決 WikiLink: ${unresolvedWikiLinks.length}件`,
|
|
2570
|
+
suggestion: `未解決リンクを修正する (${unresolvedWikiLinks.slice(0, 2).join(", ")})`,
|
|
2571
|
+
weight: 300,
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
if (orphanWarning) {
|
|
2575
|
+
issues.push({
|
|
2576
|
+
severity: "medium",
|
|
2577
|
+
category: "orphan_note",
|
|
2578
|
+
noteTitle: note.title,
|
|
2579
|
+
notePath: note.relativePath,
|
|
2580
|
+
message: "孤立ノート(被リンクなし・解決済み外向きリンクなし)",
|
|
2581
|
+
suggestion: "関連ノートとの相互リンクを追加する",
|
|
2582
|
+
weight: 220,
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
for (const warning of duplicateWarnings) {
|
|
2586
|
+
issues.push({
|
|
2587
|
+
severity: "high",
|
|
2588
|
+
category: warning.kind === "title" ? "duplicate_title" : "duplicate_permalink",
|
|
2589
|
+
noteTitle: note.title,
|
|
2590
|
+
notePath: note.relativePath,
|
|
2591
|
+
message: `${warning.kind} 重複: ${warning.value}`,
|
|
2592
|
+
suggestion: `${warning.kind} を一意にする`,
|
|
2593
|
+
weight: 260,
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
for (const violation of dependencyDirectionViolationsForNote) {
|
|
2597
|
+
dependencyDirectionViolationCount += 1;
|
|
2598
|
+
issues.push({
|
|
2599
|
+
severity: "medium",
|
|
2600
|
+
category: "dependency_direction_violation",
|
|
2601
|
+
noteTitle: note.title,
|
|
2602
|
+
notePath: note.relativePath,
|
|
2603
|
+
message: `stock ノートの親リンクが flow を指している: ${violation.parentFlowTargets.map((item) => item.title).join(", ")}`,
|
|
2604
|
+
suggestion: "親リンクを stock / index へ付け替えるか、flow 参照は関連リンクへ落とす",
|
|
2605
|
+
weight: 210,
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
if (weakStructure) {
|
|
2609
|
+
issues.push({
|
|
2610
|
+
severity: structureScore.grade === "D" ? "medium" : "low",
|
|
2611
|
+
category: "weak_structure",
|
|
2612
|
+
noteTitle: note.title,
|
|
2613
|
+
notePath: note.relativePath,
|
|
2614
|
+
message: `構造スコアが低い: ${structureScore.score} (${structureScore.grade})`,
|
|
2615
|
+
suggestion: structureScore.nextActions[0] ?? "リンクとメタデータを補完する",
|
|
2616
|
+
weight: structureScore.grade === "D" ? 180 : 120,
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
if (memoryContract.status === "fail") {
|
|
2620
|
+
const sample = memoryContract.violations.slice(0, 2).map((item) => item.code).join(", ");
|
|
2621
|
+
issues.push({
|
|
2622
|
+
severity: "medium",
|
|
2623
|
+
category: "memory_contract_violation",
|
|
2624
|
+
noteTitle: note.title,
|
|
2625
|
+
notePath: note.relativePath,
|
|
2626
|
+
message: `Memory Contract 違反: ${memoryContract.violations.length}件`,
|
|
2627
|
+
suggestion: memoryContract.violations[0]?.suggestion ?? `違反を解消する (${sample})`,
|
|
2628
|
+
weight: 200,
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
if (staleFlow) {
|
|
2632
|
+
issues.push({
|
|
2633
|
+
severity: "low",
|
|
2634
|
+
category: "stale_flow",
|
|
2635
|
+
noteTitle: note.title,
|
|
2636
|
+
notePath: note.relativePath,
|
|
2637
|
+
message: `Flow が ${staleFlowDays} 日更新されていない`,
|
|
2638
|
+
suggestion: "flow_state を見直すか、next_action を更新する",
|
|
2639
|
+
weight: 110,
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
for (const candidate of singleUseTagCandidatesForNote) {
|
|
2643
|
+
const sampleTags = candidate.tags.slice(0, 3).join(", ");
|
|
2644
|
+
issues.push({
|
|
2645
|
+
severity: "low",
|
|
2646
|
+
category: "single_use_tag_candidate",
|
|
2647
|
+
noteTitle: note.title,
|
|
2648
|
+
notePath: note.relativePath,
|
|
2649
|
+
message: `単発使用 tag 候補が多い: ${sampleTags}`,
|
|
2650
|
+
suggestion: "tag を削除するか、既存 tag へ寄せるか、同種ノートへ再利用する",
|
|
2651
|
+
weight: 70,
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
for (const candidate of titleBodyMismatchCandidatesForNote) {
|
|
2655
|
+
issues.push({
|
|
2656
|
+
severity: "low",
|
|
2657
|
+
category: "title_body_mismatch_candidate",
|
|
2658
|
+
noteTitle: note.title,
|
|
2659
|
+
notePath: note.relativePath,
|
|
2660
|
+
message: `title と本文見出しの整合が弱い: ${candidate.titleTerms.join(", ")}`,
|
|
2661
|
+
suggestion: "title を本文へ寄せるか、本文見出し/要約に中核語を反映する",
|
|
2662
|
+
weight: 80,
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
issues.sort((left, right) => {
|
|
2667
|
+
if (right.weight !== left.weight) {
|
|
2668
|
+
return right.weight - left.weight;
|
|
2669
|
+
}
|
|
2670
|
+
return left.noteTitle.localeCompare(right.noteTitle, "ja");
|
|
2671
|
+
});
|
|
2672
|
+
return {
|
|
2673
|
+
project: this.projectName,
|
|
2674
|
+
noteCount: notes.length,
|
|
2675
|
+
averageScore: Math.round(totalScore / notes.length),
|
|
2676
|
+
gradeCounts,
|
|
2677
|
+
unresolvedWikiLinkCount,
|
|
2678
|
+
orphanNoteCount,
|
|
2679
|
+
duplicateWarningCount,
|
|
2680
|
+
weakStructureCount,
|
|
2681
|
+
memoryContractViolationCount,
|
|
2682
|
+
technicalDebtSignals: {
|
|
2683
|
+
unresolvedWikilinksDelta: null,
|
|
2684
|
+
unresolvedWikilinksDeltaStatus: "requires_history",
|
|
2685
|
+
staleFlowCount,
|
|
2686
|
+
staleFlowDays: this.config.technicalDebt.staleFlowDays,
|
|
2687
|
+
staleFlowAgeBuckets,
|
|
2688
|
+
oldestStaleFlowDays,
|
|
2689
|
+
cleanupRatio: Number((cleanupReadyCount / notes.length).toFixed(3)),
|
|
2690
|
+
cleanupReadyCount,
|
|
2691
|
+
attentionNoteCount: notes.length - cleanupReadyCount,
|
|
2692
|
+
dependencyDirectionViolationCount,
|
|
2693
|
+
singleUseTagCandidateCount: singleUseTagCandidates.length,
|
|
2694
|
+
titleBodyMismatchCandidateCount: titleBodyMismatchCandidates.length,
|
|
2695
|
+
},
|
|
2696
|
+
orphanRepairQueue: orphanRepairQueue
|
|
2697
|
+
.sort((left, right) => {
|
|
2698
|
+
const leftScore = left.suggestedParent ? 1 : 0;
|
|
2699
|
+
const rightScore = right.suggestedParent ? 1 : 0;
|
|
2700
|
+
if (rightScore !== leftScore) {
|
|
2701
|
+
return rightScore - leftScore;
|
|
2702
|
+
}
|
|
2703
|
+
return left.noteTitle.localeCompare(right.noteTitle, "ja");
|
|
2704
|
+
})
|
|
2705
|
+
.slice(0, args.limit),
|
|
2706
|
+
issues: issues.slice(0, args.limit).map(({ weight: _weight, ...issue }) => issue),
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
async contextBundle(args) {
|
|
2710
|
+
const source = await this.resolveIdentifier(args.identifier);
|
|
2711
|
+
if (!source) {
|
|
2712
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
2713
|
+
}
|
|
2714
|
+
const notes = await this.loadProjectNotes();
|
|
2715
|
+
const sourceNote = notes.find((item) => item.filePath === source.filePath) ?? source;
|
|
2716
|
+
const lookup = this.buildLookup(notes);
|
|
2717
|
+
const resolvedByFilePath = new Map();
|
|
2718
|
+
const parentByFilePath = new Map();
|
|
2719
|
+
for (const note of notes) {
|
|
2720
|
+
resolvedByFilePath.set(note.filePath, this.resolvedLinkTargets(note, lookup));
|
|
2721
|
+
parentByFilePath.set(note.filePath, this.parentLinkTargets(note, lookup));
|
|
2722
|
+
}
|
|
2723
|
+
const sourceOutgoing = resolvedByFilePath.get(sourceNote.filePath) ?? new Set();
|
|
2724
|
+
const sourceParents = parentByFilePath.get(sourceNote.filePath) ?? new Set();
|
|
2725
|
+
const sourceDir = path.posix.dirname(sourceNote.relativePath);
|
|
2726
|
+
const sourceTags = new Set(safeStringArray(sourceNote.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
2727
|
+
const sourceKind = this.memoryKindForNote(sourceNote);
|
|
2728
|
+
const indexNavigation = this.indexNavigationForNote(sourceNote, notes, 3);
|
|
2729
|
+
const items = [];
|
|
2730
|
+
for (const candidate of notes) {
|
|
2731
|
+
if (candidate.filePath === sourceNote.filePath) {
|
|
2732
|
+
continue;
|
|
2733
|
+
}
|
|
2734
|
+
const reasons = [];
|
|
2735
|
+
let score = 0;
|
|
2736
|
+
const candidateOutgoing = resolvedByFilePath.get(candidate.filePath) ?? new Set();
|
|
2737
|
+
if (sourceParents.has(candidate.relativePath)) {
|
|
2738
|
+
score += 280;
|
|
2739
|
+
reasons.push("parent_link_from_source");
|
|
2740
|
+
}
|
|
2741
|
+
if (sourceOutgoing.has(candidate.relativePath)) {
|
|
2742
|
+
score += 220;
|
|
2743
|
+
reasons.push("source_links_to_candidate");
|
|
2744
|
+
}
|
|
2745
|
+
if (candidateOutgoing.has(sourceNote.relativePath)) {
|
|
2746
|
+
score += 220;
|
|
2747
|
+
reasons.push("candidate_backlinks_source");
|
|
2748
|
+
}
|
|
2749
|
+
const sharedLinks = intersectCount(sourceOutgoing, candidateOutgoing);
|
|
2750
|
+
if (sharedLinks > 0) {
|
|
2751
|
+
score += Math.min(150, sharedLinks * 30);
|
|
2752
|
+
reasons.push(`shared_outgoing_links:${sharedLinks}`);
|
|
2753
|
+
}
|
|
2754
|
+
const candidateTags = new Set(safeStringArray(candidate.frontmatter.tags).map((tag) => normalizeLookup(tag)));
|
|
2755
|
+
const sharedTags = intersectCount(sourceTags, candidateTags);
|
|
2756
|
+
if (sharedTags > 0) {
|
|
2757
|
+
score += Math.min(90, sharedTags * 18);
|
|
2758
|
+
reasons.push(`shared_tags:${sharedTags}`);
|
|
2759
|
+
}
|
|
2760
|
+
const candidateDir = path.posix.dirname(candidate.relativePath);
|
|
2761
|
+
if (candidateDir === sourceDir) {
|
|
2762
|
+
score += 40;
|
|
2763
|
+
reasons.push("same_directory");
|
|
2764
|
+
}
|
|
2765
|
+
else if (sourceDir !== "."
|
|
2766
|
+
&& candidateDir !== "."
|
|
2767
|
+
&& (candidateDir.startsWith(`${sourceDir}/`) || sourceDir.startsWith(`${candidateDir}/`))) {
|
|
2768
|
+
score += 20;
|
|
2769
|
+
reasons.push("near_directory");
|
|
2770
|
+
}
|
|
2771
|
+
if (this.memoryKindForNote(candidate) === sourceKind) {
|
|
2772
|
+
score += 10;
|
|
2773
|
+
reasons.push("same_memory_kind");
|
|
2774
|
+
}
|
|
2775
|
+
if (score <= 0) {
|
|
2776
|
+
continue;
|
|
2777
|
+
}
|
|
2778
|
+
items.push({
|
|
2779
|
+
note: candidate,
|
|
2780
|
+
score,
|
|
2781
|
+
reasons: uniqueStrings(reasons),
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
items.sort((left, right) => {
|
|
2785
|
+
if (right.score !== left.score) {
|
|
2786
|
+
return right.score - left.score;
|
|
2787
|
+
}
|
|
2788
|
+
if (right.note.updated !== left.note.updated) {
|
|
2789
|
+
return right.note.updated.localeCompare(left.note.updated);
|
|
2790
|
+
}
|
|
2791
|
+
return left.note.title.localeCompare(right.note.title, "ja");
|
|
2792
|
+
});
|
|
2793
|
+
const rankedNotes = items.map((item) => item.note);
|
|
2794
|
+
return {
|
|
2795
|
+
project: this.projectName,
|
|
2796
|
+
source: {
|
|
2797
|
+
title: sourceNote.title,
|
|
2798
|
+
permalink: sourceNote.permalink,
|
|
2799
|
+
filePath: sourceNote.relativePath,
|
|
2800
|
+
memoryKind: sourceKind,
|
|
2801
|
+
boundaryWarnings: this.boundaryWarningsForNote(sourceNote),
|
|
2802
|
+
indexLike: indexNavigation.indexLike,
|
|
2803
|
+
indexReasons: indexNavigation.indexReasons,
|
|
2804
|
+
},
|
|
2805
|
+
items: items.slice(0, args.limit).map((item) => ({
|
|
2806
|
+
title: item.note.title,
|
|
2807
|
+
permalink: item.note.permalink,
|
|
2808
|
+
filePath: item.note.relativePath,
|
|
2809
|
+
memoryKind: this.memoryKindForNote(item.note),
|
|
2810
|
+
score: item.score,
|
|
2811
|
+
reasons: item.reasons,
|
|
2812
|
+
})),
|
|
2813
|
+
indexNoteCandidates: indexNavigation.indexNoteCandidates,
|
|
2814
|
+
explorationGuidance: this.contextExplorationGuidance(notes, sourceNote, rankedNotes),
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
async writeFlowNote(args) {
|
|
2818
|
+
await this.storage.ensureRoot();
|
|
2819
|
+
const flowState = (safeString(args.flowState)?.toLowerCase() ?? "capture");
|
|
2820
|
+
if (!FLOW_STATES.has(flowState)) {
|
|
2821
|
+
throw new Error(`invalid flow_state: ${flowState}`);
|
|
2822
|
+
}
|
|
2823
|
+
const question = args.question.trim();
|
|
2824
|
+
const nextAction = args.nextAction.trim();
|
|
2825
|
+
if (question.length === 0) {
|
|
2826
|
+
throw new Error("question is required");
|
|
2827
|
+
}
|
|
2828
|
+
if (nextAction.length === 0) {
|
|
2829
|
+
throw new Error("next_action is required");
|
|
2830
|
+
}
|
|
2831
|
+
const parentStock = normalizeWikiTargetInput(args.parentStock);
|
|
2832
|
+
if (parentStock.length === 0) {
|
|
2833
|
+
throw new Error("parent_stock is required");
|
|
2834
|
+
}
|
|
2835
|
+
const relatedTargets = uniqueStrings((args.related ?? [])
|
|
2836
|
+
.map((item) => normalizeWikiTargetInput(item))
|
|
2837
|
+
.filter((item) => item.length > 0)).filter((item) => item !== parentStock);
|
|
2838
|
+
if (relatedTargets.length === 0) {
|
|
2839
|
+
relatedTargets.push(parentStock);
|
|
2840
|
+
}
|
|
2841
|
+
const lines = [];
|
|
2842
|
+
lines.push(`- 親: [[${parentStock}]]`);
|
|
2843
|
+
for (const target of relatedTargets) {
|
|
2844
|
+
lines.push(`- 関連: [[${target}]]`);
|
|
2845
|
+
}
|
|
2846
|
+
lines.push("");
|
|
2847
|
+
lines.push("## Question");
|
|
2848
|
+
lines.push(question);
|
|
2849
|
+
lines.push("");
|
|
2850
|
+
lines.push("## Next Action");
|
|
2851
|
+
lines.push(nextAction);
|
|
2852
|
+
const details = normalizeContent(args.details ?? "").trim();
|
|
2853
|
+
if (details.length > 0) {
|
|
2854
|
+
lines.push("");
|
|
2855
|
+
lines.push("## Notes");
|
|
2856
|
+
lines.push(details);
|
|
2857
|
+
}
|
|
2858
|
+
const normalizedTags = args.tags
|
|
2859
|
+
? args.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)
|
|
2860
|
+
: ["flow"];
|
|
2861
|
+
const result = await this.writeNote({
|
|
2862
|
+
directory: args.directory ?? FLOW_DEFAULT_DIRECTORY,
|
|
2863
|
+
title: args.title,
|
|
2864
|
+
content: lines.join("\n"),
|
|
2865
|
+
tags: normalizedTags,
|
|
2866
|
+
noteType: "flow",
|
|
2867
|
+
frontmatterPatch: {
|
|
2868
|
+
memory_kind: "flow",
|
|
2869
|
+
flow_state: flowState,
|
|
2870
|
+
question,
|
|
2871
|
+
next_action: nextAction,
|
|
2872
|
+
parent_stock: parentStock,
|
|
2873
|
+
},
|
|
2874
|
+
});
|
|
2875
|
+
const notes = await this.loadProjectNotes();
|
|
2876
|
+
const lookup = this.buildLookup(notes);
|
|
2877
|
+
const parentResolved = this.resolveWikiTarget(parentStock, lookup);
|
|
2878
|
+
const parentStockResolved = parentResolved.status === "resolved" && parentResolved.note
|
|
2879
|
+
? this.memoryKindForNote(parentResolved.note) === "stock"
|
|
2880
|
+
: false;
|
|
2881
|
+
return {
|
|
2882
|
+
...result,
|
|
2883
|
+
flowState,
|
|
2884
|
+
parentStock,
|
|
2885
|
+
parentStockResolved,
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
async promoteToStock(args) {
|
|
2889
|
+
const sourceFlow = await this.resolveIdentifier(args.flowIdentifier);
|
|
2890
|
+
if (!sourceFlow) {
|
|
2891
|
+
throw new Error(`note not found: ${args.flowIdentifier}`);
|
|
2892
|
+
}
|
|
2893
|
+
const notes = await this.loadProjectNotes();
|
|
2894
|
+
const currentFlow = notes.find((item) => item.filePath === sourceFlow.filePath) ?? sourceFlow;
|
|
2895
|
+
if (this.memoryKindForNote(currentFlow) !== "flow") {
|
|
2896
|
+
throw new Error(`source note is not flow: ${currentFlow.relativePath}`);
|
|
2897
|
+
}
|
|
2898
|
+
const lookup = this.buildLookup(notes);
|
|
2899
|
+
const parentStockRaw = safeString(currentFlow.frontmatter.parent_stock);
|
|
2900
|
+
const parentStock = normalizeWikiTargetInput(parentStockRaw ?? "");
|
|
2901
|
+
if (parentStock.length === 0) {
|
|
2902
|
+
throw new Error("flow note has no parent_stock");
|
|
2903
|
+
}
|
|
2904
|
+
const parentResolved = this.resolveWikiTarget(parentStock, lookup);
|
|
2905
|
+
if (parentResolved.status !== "resolved" || !parentResolved.note) {
|
|
2906
|
+
throw new Error(`parent_stock is unresolved: ${parentStock}`);
|
|
2907
|
+
}
|
|
2908
|
+
if (this.memoryKindForNote(parentResolved.note) !== "stock") {
|
|
2909
|
+
throw new Error(`parent_stock is not stock: ${parentResolved.note.title}`);
|
|
2910
|
+
}
|
|
2911
|
+
const stockTitle = safeString(args.stockTitle) ?? currentFlow.title;
|
|
2912
|
+
const summary = (extractSectionBody(currentFlow.body, "Conclusion")
|
|
2913
|
+
|| extractSectionBody(currentFlow.body, "結論")
|
|
2914
|
+
|| extractSectionBody(currentFlow.body, "Summary")
|
|
2915
|
+
|| extractSectionBody(currentFlow.body, "要約")
|
|
2916
|
+
|| safeString(currentFlow.frontmatter.question)
|
|
2917
|
+
|| excerptBody(currentFlow.body, 5, 360)).trim();
|
|
2918
|
+
const stockLines = [];
|
|
2919
|
+
stockLines.push(`- 親: [[${parentResolved.note.permalink}]]`);
|
|
2920
|
+
stockLines.push(`- 関連: [[${currentFlow.permalink}]]`);
|
|
2921
|
+
stockLines.push("");
|
|
2922
|
+
stockLines.push("## Claim");
|
|
2923
|
+
stockLines.push(summary.length > 0 ? summary : "(to be filled)");
|
|
2924
|
+
stockLines.push("");
|
|
2925
|
+
stockLines.push("## Evidence");
|
|
2926
|
+
stockLines.push(`- Source Flow: [[${currentFlow.permalink}]]`);
|
|
2927
|
+
const flowNotes = extractSectionBody(currentFlow.body, "Notes") || excerptBody(currentFlow.body, 8, 640);
|
|
2928
|
+
if (flowNotes.length > 0) {
|
|
2929
|
+
stockLines.push("");
|
|
2930
|
+
stockLines.push("## Notes");
|
|
2931
|
+
stockLines.push(flowNotes);
|
|
2932
|
+
}
|
|
2933
|
+
const inheritedTags = safeStringArray(currentFlow.frontmatter.tags);
|
|
2934
|
+
const stockTags = args.tags
|
|
2935
|
+
? args.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)
|
|
2936
|
+
: uniqueStrings([...inheritedTags, "stock"]);
|
|
2937
|
+
const stockPrepared = await this.prepareNoteContent({
|
|
2938
|
+
directory: args.directory ?? STOCK_DEFAULT_DIRECTORY,
|
|
2939
|
+
title: stockTitle,
|
|
2940
|
+
content: stockLines.join("\n"),
|
|
2941
|
+
tags: stockTags,
|
|
2942
|
+
noteType: args.noteType ?? "stock",
|
|
2943
|
+
frontmatterPatch: {
|
|
2944
|
+
memory_kind: "stock",
|
|
2945
|
+
source_flow: currentFlow.permalink,
|
|
2946
|
+
},
|
|
2947
|
+
});
|
|
2948
|
+
let nextFlowBody = currentFlow.body.trimEnd();
|
|
2949
|
+
if (!nextFlowBody.includes(`[[${stockPrepared.permalink}]]`)) {
|
|
2950
|
+
const promotionSection = `## Promotion\n- 昇格先: [[${stockPrepared.permalink}]]`;
|
|
2951
|
+
nextFlowBody = nextFlowBody.length > 0
|
|
2952
|
+
? `${nextFlowBody}\n\n${promotionSection}`
|
|
2953
|
+
: promotionSection;
|
|
2954
|
+
}
|
|
2955
|
+
const updatedFlowFrontmatter = {
|
|
2956
|
+
...currentFlow.frontmatter,
|
|
2957
|
+
title: currentFlow.title,
|
|
2958
|
+
type: safeString(currentFlow.frontmatter.type) ?? "flow",
|
|
2959
|
+
memory_kind: "flow",
|
|
2960
|
+
flow_state: "promoted",
|
|
2961
|
+
parent_stock: parentResolved.note.permalink,
|
|
2962
|
+
promoted_to: stockPrepared.permalink,
|
|
2963
|
+
permalink: currentFlow.permalink,
|
|
2964
|
+
tags: safeStringArray(currentFlow.frontmatter.tags).length > 0
|
|
2965
|
+
? safeStringArray(currentFlow.frontmatter.tags)
|
|
2966
|
+
: ["flow"],
|
|
2967
|
+
created: currentFlow.created,
|
|
2968
|
+
updated: nowDate(),
|
|
2969
|
+
};
|
|
2970
|
+
const renderedFlow = `${buildFrontmatter(updatedFlowFrontmatter)}\n\n${ensureTrailingNewline(nextFlowBody)}`;
|
|
2971
|
+
await this.storage.applyBatch([
|
|
2972
|
+
{ kind: "write", relativePath: stockPrepared.relativePath, content: stockPrepared.content },
|
|
2973
|
+
{ kind: "write", relativePath: currentFlow.relativePath, content: renderedFlow },
|
|
2974
|
+
]);
|
|
2975
|
+
return {
|
|
2976
|
+
project: this.projectName,
|
|
2977
|
+
sourceFlow: {
|
|
2978
|
+
title: currentFlow.title,
|
|
2979
|
+
relativePath: currentFlow.relativePath,
|
|
2980
|
+
permalink: currentFlow.permalink,
|
|
2981
|
+
},
|
|
2982
|
+
stock: {
|
|
2983
|
+
title: stockTitle,
|
|
2984
|
+
relativePath: stockPrepared.relativePath,
|
|
2985
|
+
permalink: stockPrepared.permalink,
|
|
2986
|
+
},
|
|
2987
|
+
flowState: "promoted",
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
async prepareNoteContent(input) {
|
|
2991
|
+
const directory = sanitizeRelativePath(input.directory ?? this.config.defaultDirectory);
|
|
2992
|
+
const fileBase = sanitizeFileBase(input.title);
|
|
2993
|
+
const fileName = `${fileBase}${MARKDOWN_EXTENSION}`;
|
|
2994
|
+
const relativePath = directory.length > 0 ? `${directory}/${fileName}` : fileName;
|
|
2995
|
+
const targetPath = path.resolve(this.root, relativePath);
|
|
2996
|
+
if (!isInsideDirectory(this.root, targetPath)) {
|
|
2997
|
+
throw new Error("invalid target path");
|
|
2998
|
+
}
|
|
2999
|
+
let operation = "created";
|
|
3000
|
+
let existingFrontmatter = {};
|
|
3001
|
+
try {
|
|
3002
|
+
const current = await this.loadNote(relativePath);
|
|
3003
|
+
existingFrontmatter = current.frontmatter;
|
|
3004
|
+
operation = "updated";
|
|
3005
|
+
}
|
|
3006
|
+
catch {
|
|
3007
|
+
// create new file
|
|
3008
|
+
}
|
|
3009
|
+
const date = nowDate();
|
|
3010
|
+
const permalink = safeString(existingFrontmatter.permalink)
|
|
3011
|
+
?? (relativePath.endsWith(MARKDOWN_EXTENSION)
|
|
3012
|
+
? relativePath.slice(0, -MARKDOWN_EXTENSION.length)
|
|
3013
|
+
: relativePath);
|
|
3014
|
+
const patch = normalizeFrontmatterPatch(input.frontmatterPatch);
|
|
3015
|
+
const patchTags = Array.isArray(patch.tags) ? safeStringArray(patch.tags) : undefined;
|
|
3016
|
+
const nextTags = input.tags
|
|
3017
|
+
? input.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)
|
|
3018
|
+
: patchTags ?? safeStringArray(existingFrontmatter.tags);
|
|
3019
|
+
const nextType = input.noteType
|
|
3020
|
+
?? safeString(patch.type)
|
|
3021
|
+
?? safeString(existingFrontmatter.type)
|
|
3022
|
+
?? "note";
|
|
3023
|
+
const frontmatter = {
|
|
3024
|
+
...existingFrontmatter,
|
|
3025
|
+
title: input.title,
|
|
3026
|
+
type: nextType,
|
|
3027
|
+
permalink,
|
|
3028
|
+
tags: nextTags,
|
|
3029
|
+
created: safeString(existingFrontmatter.created) ?? date,
|
|
3030
|
+
updated: date,
|
|
3031
|
+
};
|
|
3032
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3033
|
+
if (RESERVED_FRONTMATTER_KEYS.has(key)) {
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
3036
|
+
frontmatter[key] = value;
|
|
3037
|
+
}
|
|
3038
|
+
const body = normalizeContent(input.content).trimEnd();
|
|
3039
|
+
const content = `${buildFrontmatter(frontmatter)}\n\n${ensureTrailingNewline(body)}`;
|
|
3040
|
+
return { relativePath, targetPath, content, permalink, operation, title: input.title };
|
|
3041
|
+
}
|
|
3042
|
+
async writeNote(input) {
|
|
3043
|
+
await this.storage.ensureRoot();
|
|
3044
|
+
const prepared = await this.prepareNoteContent(input);
|
|
3045
|
+
await this.storage.writeFile(prepared.relativePath, prepared.content);
|
|
3046
|
+
const saved = await this.loadNote(prepared.relativePath);
|
|
3047
|
+
const notes = await this.loadProjectNotes();
|
|
3048
|
+
const signals = this.qualitySignalsForNote(saved, notes);
|
|
3049
|
+
return {
|
|
3050
|
+
project: this.projectName,
|
|
3051
|
+
operation: prepared.operation,
|
|
3052
|
+
filePath: prepared.targetPath,
|
|
3053
|
+
relativePath: prepared.relativePath,
|
|
3054
|
+
title: prepared.title,
|
|
3055
|
+
permalink: prepared.permalink,
|
|
3056
|
+
serverUpdated: nowTimestamp(),
|
|
3057
|
+
linkHealth: signals.linkHealth,
|
|
3058
|
+
unresolvedWikiLinks: signals.unresolvedWikiLinks,
|
|
3059
|
+
unresolvedLinkHints: signals.unresolvedLinkHints,
|
|
3060
|
+
backlinkCount: signals.backlinkCount,
|
|
3061
|
+
orphanWarning: signals.orphanWarning,
|
|
3062
|
+
duplicateWarnings: signals.duplicateWarnings,
|
|
3063
|
+
structureScore: signals.structureScore,
|
|
3064
|
+
memoryContract: signals.memoryContract,
|
|
3065
|
+
boundaryWarnings: signals.boundaryWarnings,
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
async appendNote(args) {
|
|
3069
|
+
if (args.createIfMissing) {
|
|
3070
|
+
await this.storage.ensureRoot();
|
|
3071
|
+
}
|
|
3072
|
+
const note = await this.resolveIdentifier(args.identifier);
|
|
3073
|
+
if (!note) {
|
|
3074
|
+
if (!args.createIfMissing) {
|
|
3075
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
3076
|
+
}
|
|
3077
|
+
const created = await this.writeNote({
|
|
3078
|
+
directory: args.directory,
|
|
3079
|
+
title: inferTitleFromIdentifier(args.identifier),
|
|
3080
|
+
content: normalizeContent(args.content).trimEnd(),
|
|
3081
|
+
tags: args.tags,
|
|
3082
|
+
noteType: args.noteType,
|
|
3083
|
+
});
|
|
3084
|
+
return {
|
|
3085
|
+
project: created.project,
|
|
3086
|
+
filePath: created.filePath,
|
|
3087
|
+
relativePath: created.relativePath,
|
|
3088
|
+
title: created.title,
|
|
3089
|
+
permalink: created.permalink,
|
|
3090
|
+
operation: "created",
|
|
3091
|
+
serverUpdated: created.serverUpdated,
|
|
3092
|
+
linkHealth: created.linkHealth,
|
|
3093
|
+
unresolvedWikiLinks: created.unresolvedWikiLinks,
|
|
3094
|
+
unresolvedLinkHints: created.unresolvedLinkHints,
|
|
3095
|
+
backlinkCount: created.backlinkCount,
|
|
3096
|
+
orphanWarning: created.orphanWarning,
|
|
3097
|
+
duplicateWarnings: created.duplicateWarnings,
|
|
3098
|
+
structureScore: created.structureScore,
|
|
3099
|
+
memoryContract: created.memoryContract,
|
|
3100
|
+
boundaryWarnings: created.boundaryWarnings,
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
const date = nowDate();
|
|
3104
|
+
const nextBody = `${note.body.trimEnd()}\n\n${normalizeContent(args.content).trimEnd()}\n`;
|
|
3105
|
+
const frontmatter = {
|
|
3106
|
+
...note.frontmatter,
|
|
3107
|
+
title: note.title,
|
|
3108
|
+
permalink: note.permalink,
|
|
3109
|
+
created: note.created,
|
|
3110
|
+
updated: date,
|
|
3111
|
+
};
|
|
3112
|
+
const rendered = `${buildFrontmatter(frontmatter)}\n\n${nextBody}`;
|
|
3113
|
+
await this.storage.writeFile(note.relativePath, rendered);
|
|
3114
|
+
const updated = await this.loadNote(note.relativePath);
|
|
3115
|
+
const notes = await this.loadProjectNotes();
|
|
3116
|
+
const signals = this.qualitySignalsForNote(updated, notes);
|
|
3117
|
+
return {
|
|
3118
|
+
project: this.projectName,
|
|
3119
|
+
filePath: note.filePath,
|
|
3120
|
+
relativePath: note.relativePath,
|
|
3121
|
+
title: note.title,
|
|
3122
|
+
permalink: note.permalink,
|
|
3123
|
+
operation: "appended",
|
|
3124
|
+
serverUpdated: nowTimestamp(),
|
|
3125
|
+
linkHealth: signals.linkHealth,
|
|
3126
|
+
unresolvedWikiLinks: signals.unresolvedWikiLinks,
|
|
3127
|
+
unresolvedLinkHints: signals.unresolvedLinkHints,
|
|
3128
|
+
backlinkCount: signals.backlinkCount,
|
|
3129
|
+
orphanWarning: signals.orphanWarning,
|
|
3130
|
+
duplicateWarnings: signals.duplicateWarnings,
|
|
3131
|
+
structureScore: signals.structureScore,
|
|
3132
|
+
memoryContract: signals.memoryContract,
|
|
3133
|
+
boundaryWarnings: signals.boundaryWarnings,
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
async renameNote(args) {
|
|
3137
|
+
const beforeNotes = await this.loadProjectNotes();
|
|
3138
|
+
const projectLinkHealthBefore = this.projectLinkHealth(beforeNotes);
|
|
3139
|
+
const note = await this.resolveIdentifier(args.identifier);
|
|
3140
|
+
if (!note) {
|
|
3141
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
3142
|
+
}
|
|
3143
|
+
const oldTitle = note.title;
|
|
3144
|
+
const oldRelativePath = note.relativePath;
|
|
3145
|
+
const oldPermalink = note.permalink;
|
|
3146
|
+
const oldKeys = this.identifierKeys(note);
|
|
3147
|
+
let nextDirectory = args.directory ? sanitizeRelativePath(args.directory) : path.posix.dirname(note.relativePath);
|
|
3148
|
+
if (nextDirectory === ".") {
|
|
3149
|
+
nextDirectory = "";
|
|
3150
|
+
}
|
|
3151
|
+
const newFileBase = sanitizeFileBase(args.newTitle);
|
|
3152
|
+
const newRelativePath = nextDirectory.length > 0
|
|
3153
|
+
? `${nextDirectory}/${newFileBase}${MARKDOWN_EXTENSION}`
|
|
3154
|
+
: `${newFileBase}${MARKDOWN_EXTENSION}`;
|
|
3155
|
+
const newAbsolutePath = path.resolve(this.root, newRelativePath);
|
|
3156
|
+
if (!isInsideDirectory(this.root, newAbsolutePath)) {
|
|
3157
|
+
throw new Error("invalid rename path");
|
|
3158
|
+
}
|
|
3159
|
+
const samePath = oldRelativePath === newRelativePath;
|
|
3160
|
+
if (!samePath) {
|
|
3161
|
+
const targetStat = await this.storage.stat(newRelativePath);
|
|
3162
|
+
if (targetStat.exists && targetStat.isFile) {
|
|
3163
|
+
throw new Error(`target file already exists: ${newRelativePath}`);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
const date = nowDate();
|
|
3167
|
+
const newPermalink = newRelativePath.endsWith(MARKDOWN_EXTENSION)
|
|
3168
|
+
? newRelativePath.slice(0, -MARKDOWN_EXTENSION.length)
|
|
3169
|
+
: newRelativePath;
|
|
3170
|
+
const frontmatter = {
|
|
3171
|
+
...note.frontmatter,
|
|
3172
|
+
title: args.newTitle,
|
|
3173
|
+
permalink: newPermalink,
|
|
3174
|
+
created: note.created,
|
|
3175
|
+
updated: date,
|
|
3176
|
+
};
|
|
3177
|
+
const rewrittenTarget = `${buildFrontmatter(frontmatter)}\n\n${ensureTrailingNewline(note.body.trimEnd())}`;
|
|
3178
|
+
const ops = [];
|
|
3179
|
+
let finalTargetContent = rewrittenTarget;
|
|
3180
|
+
let updatedFiles = 0;
|
|
3181
|
+
let updatedLinks = 0;
|
|
3182
|
+
if (!samePath) {
|
|
3183
|
+
ops.push({ kind: "delete", relativePath: oldRelativePath });
|
|
3184
|
+
}
|
|
3185
|
+
if (args.updateLinks !== false) {
|
|
3186
|
+
const oldKeyLowerSet = new Set(oldKeys.map((key) => key.toLowerCase()));
|
|
3187
|
+
const oldKeyNormalizedSet = new Set(oldKeys.map((key) => normalizeLookup(key)));
|
|
3188
|
+
const linkRewriter = (token) => {
|
|
3189
|
+
const targetLower = token.targetBase.toLowerCase();
|
|
3190
|
+
const targetNormalized = normalizeLookup(token.targetBase);
|
|
3191
|
+
const matches = oldKeyLowerSet.has(targetLower)
|
|
3192
|
+
|| (targetNormalized.length > 0 && oldKeyNormalizedSet.has(targetNormalized));
|
|
3193
|
+
if (!matches) {
|
|
3194
|
+
return undefined;
|
|
3195
|
+
}
|
|
3196
|
+
return renderWikiLink(newPermalink, token.suffix, token.alias);
|
|
3197
|
+
};
|
|
3198
|
+
const targetLinkResult = rewriteWikiLinks(rewrittenTarget, linkRewriter);
|
|
3199
|
+
finalTargetContent = targetLinkResult.text;
|
|
3200
|
+
updatedLinks += targetLinkResult.replaced;
|
|
3201
|
+
for (const source of beforeNotes) {
|
|
3202
|
+
if (source.relativePath === oldRelativePath) {
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
3205
|
+
const rewritten = rewriteWikiLinks(source.raw, linkRewriter);
|
|
3206
|
+
if (rewritten.replaced > 0) {
|
|
3207
|
+
ops.push({ kind: "write", relativePath: source.relativePath, content: rewritten.text });
|
|
3208
|
+
updatedFiles += 1;
|
|
3209
|
+
updatedLinks += rewritten.replaced;
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
ops.push({ kind: "write", relativePath: newRelativePath, content: finalTargetContent });
|
|
3214
|
+
await this.storage.applyBatch(ops);
|
|
3215
|
+
const afterNotes = await this.loadProjectNotes();
|
|
3216
|
+
const projectLinkHealthAfter = this.projectLinkHealth(afterNotes);
|
|
3217
|
+
const renamed = await this.loadNote(newRelativePath);
|
|
3218
|
+
const signals = this.qualitySignalsForNote(renamed, afterNotes);
|
|
3219
|
+
return {
|
|
3220
|
+
project: this.projectName,
|
|
3221
|
+
oldTitle,
|
|
3222
|
+
newTitle: renamed.title,
|
|
3223
|
+
oldRelativePath,
|
|
3224
|
+
newRelativePath: renamed.relativePath,
|
|
3225
|
+
oldPermalink,
|
|
3226
|
+
newPermalink: renamed.permalink,
|
|
3227
|
+
serverUpdated: nowTimestamp(),
|
|
3228
|
+
updatedLinks,
|
|
3229
|
+
updatedFiles,
|
|
3230
|
+
renamedNoteLinkHealth: signals.linkHealth,
|
|
3231
|
+
backlinkCount: signals.backlinkCount,
|
|
3232
|
+
orphanWarning: signals.orphanWarning,
|
|
3233
|
+
projectLinkHealthBefore,
|
|
3234
|
+
projectLinkHealthAfter,
|
|
3235
|
+
unresolvedWikiLinks: signals.unresolvedWikiLinks,
|
|
3236
|
+
unresolvedLinkHints: signals.unresolvedLinkHints,
|
|
3237
|
+
structureScore: signals.structureScore,
|
|
3238
|
+
memoryContract: signals.memoryContract,
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
async deleteNote(args) {
|
|
3242
|
+
const beforeNotes = await this.loadProjectNotes();
|
|
3243
|
+
const projectLinkHealthBefore = this.projectLinkHealth(beforeNotes);
|
|
3244
|
+
const note = await this.resolveIdentifier(args.identifier);
|
|
3245
|
+
if (!note) {
|
|
3246
|
+
throw new Error(`note not found: ${args.identifier}`);
|
|
3247
|
+
}
|
|
3248
|
+
const current = beforeNotes.find((item) => item.filePath === note.filePath) ?? note;
|
|
3249
|
+
const signalsBeforeDelete = this.qualitySignalsForNote(current, beforeNotes);
|
|
3250
|
+
await this.storage.deleteFile(current.relativePath);
|
|
3251
|
+
const afterNotes = await this.loadProjectNotes();
|
|
3252
|
+
const projectLinkHealthAfter = this.projectLinkHealth(afterNotes);
|
|
3253
|
+
const projectSignalsAfter = this.projectUnresolvedSignals(afterNotes);
|
|
3254
|
+
return {
|
|
3255
|
+
project: this.projectName,
|
|
3256
|
+
title: current.title,
|
|
3257
|
+
relativePath: current.relativePath,
|
|
3258
|
+
permalink: current.permalink,
|
|
3259
|
+
serverUpdated: nowTimestamp(),
|
|
3260
|
+
backlinksToDeleted: signalsBeforeDelete.backlinkCount,
|
|
3261
|
+
deletedNoteLinkHealth: signalsBeforeDelete.linkHealth,
|
|
3262
|
+
projectLinkHealthBefore,
|
|
3263
|
+
projectLinkHealthAfter,
|
|
3264
|
+
unresolvedWikiLinks: projectSignalsAfter.unresolvedWikiLinks,
|
|
3265
|
+
unresolvedLinkHints: projectSignalsAfter.unresolvedLinkHints,
|
|
3266
|
+
structureScoreBeforeDelete: signalsBeforeDelete.structureScore,
|
|
3267
|
+
memoryContractBeforeDelete: signalsBeforeDelete.memoryContract,
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
}
|