@jamesaphoenix/tx-core 0.5.9 → 0.6.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.
Files changed (108) hide show
  1. package/README.md +54 -4
  2. package/dist/errors.d.ts +25 -1
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/errors.js +16 -0
  5. package/dist/errors.js.map +1 -1
  6. package/dist/index.d.ts +6 -5
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +8 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/layer.d.ts +8 -4
  11. package/dist/layer.d.ts.map +1 -1
  12. package/dist/layer.js +25 -4
  13. package/dist/layer.js.map +1 -1
  14. package/dist/mappers/index.d.ts +1 -0
  15. package/dist/mappers/index.d.ts.map +1 -1
  16. package/dist/mappers/index.js +2 -0
  17. package/dist/mappers/index.js.map +1 -1
  18. package/dist/mappers/memory.d.ts +34 -0
  19. package/dist/mappers/memory.d.ts.map +1 -0
  20. package/dist/mappers/memory.js +135 -0
  21. package/dist/mappers/memory.js.map +1 -0
  22. package/dist/mappers/pin.d.ts +6 -0
  23. package/dist/mappers/pin.d.ts.map +1 -0
  24. package/dist/mappers/pin.js +10 -0
  25. package/dist/mappers/pin.js.map +1 -0
  26. package/dist/migrations-embedded.d.ts +9 -0
  27. package/dist/migrations-embedded.d.ts.map +1 -0
  28. package/dist/migrations-embedded.js +153 -0
  29. package/dist/migrations-embedded.js.map +1 -0
  30. package/dist/repo/index.d.ts +2 -0
  31. package/dist/repo/index.d.ts.map +1 -1
  32. package/dist/repo/index.js +2 -0
  33. package/dist/repo/index.js.map +1 -1
  34. package/dist/repo/memory-repo.d.ts +88 -0
  35. package/dist/repo/memory-repo.d.ts.map +1 -0
  36. package/dist/repo/memory-repo.js +556 -0
  37. package/dist/repo/memory-repo.js.map +1 -0
  38. package/dist/repo/pin-repo.d.ts +29 -0
  39. package/dist/repo/pin-repo.d.ts.map +1 -0
  40. package/dist/repo/pin-repo.js +79 -0
  41. package/dist/repo/pin-repo.js.map +1 -0
  42. package/dist/schemas/index.d.ts +2 -2
  43. package/dist/schemas/index.d.ts.map +1 -1
  44. package/dist/schemas/index.js +1 -1
  45. package/dist/schemas/index.js.map +1 -1
  46. package/dist/schemas/sync.d.ts +598 -3
  47. package/dist/schemas/sync.d.ts.map +1 -1
  48. package/dist/schemas/sync.js +280 -6
  49. package/dist/schemas/sync.js.map +1 -1
  50. package/dist/services/agent-service.d.ts +2 -0
  51. package/dist/services/agent-service.d.ts.map +1 -1
  52. package/dist/services/agent-service.js +15 -1
  53. package/dist/services/agent-service.js.map +1 -1
  54. package/dist/services/cycle-scan-service.d.ts.map +1 -1
  55. package/dist/services/cycle-scan-service.js +11 -7
  56. package/dist/services/cycle-scan-service.js.map +1 -1
  57. package/dist/services/diversifier-service.d.ts +2 -2
  58. package/dist/services/diversifier-service.d.ts.map +1 -1
  59. package/dist/services/diversifier-service.js.map +1 -1
  60. package/dist/services/index.d.ts +4 -2
  61. package/dist/services/index.d.ts.map +1 -1
  62. package/dist/services/index.js +3 -1
  63. package/dist/services/index.js.map +1 -1
  64. package/dist/services/learning-service.d.ts +3 -2
  65. package/dist/services/learning-service.d.ts.map +1 -1
  66. package/dist/services/learning-service.js.map +1 -1
  67. package/dist/services/memory-retriever-service.d.ts +48 -0
  68. package/dist/services/memory-retriever-service.d.ts.map +1 -0
  69. package/dist/services/memory-retriever-service.js +332 -0
  70. package/dist/services/memory-retriever-service.js.map +1 -0
  71. package/dist/services/memory-service.d.ts +49 -0
  72. package/dist/services/memory-service.d.ts.map +1 -0
  73. package/dist/services/memory-service.js +1061 -0
  74. package/dist/services/memory-service.js.map +1 -0
  75. package/dist/services/migration-service.d.ts +8 -2
  76. package/dist/services/migration-service.d.ts.map +1 -1
  77. package/dist/services/migration-service.js +23 -8
  78. package/dist/services/migration-service.js.map +1 -1
  79. package/dist/services/pin-service.d.ts +33 -0
  80. package/dist/services/pin-service.d.ts.map +1 -0
  81. package/dist/services/pin-service.js +140 -0
  82. package/dist/services/pin-service.js.map +1 -0
  83. package/dist/services/ready-service.js +1 -1
  84. package/dist/services/ready-service.js.map +1 -1
  85. package/dist/services/retriever-service.d.ts +2 -2
  86. package/dist/services/retriever-service.d.ts.map +1 -1
  87. package/dist/services/retriever-service.js.map +1 -1
  88. package/dist/services/sync-service.d.ts +123 -4
  89. package/dist/services/sync-service.d.ts.map +1 -1
  90. package/dist/services/sync-service.js +1099 -11
  91. package/dist/services/sync-service.js.map +1 -1
  92. package/dist/services/task-service.js +2 -2
  93. package/dist/services/task-service.js.map +1 -1
  94. package/dist/utils/math.d.ts +4 -3
  95. package/dist/utils/math.d.ts.map +1 -1
  96. package/dist/utils/math.js +9 -4
  97. package/dist/utils/math.js.map +1 -1
  98. package/dist/utils/pin-file.d.ts +33 -0
  99. package/dist/utils/pin-file.d.ts.map +1 -0
  100. package/dist/utils/pin-file.js +87 -0
  101. package/dist/utils/pin-file.js.map +1 -0
  102. package/dist/utils/toml-config.d.ts +12 -0
  103. package/dist/utils/toml-config.d.ts.map +1 -1
  104. package/dist/utils/toml-config.js +111 -1
  105. package/dist/utils/toml-config.js.map +1 -1
  106. package/migrations/029_memory.sql +83 -0
  107. package/migrations/030_context_pins.sql +24 -0
  108. package/package.json +3 -2
@@ -0,0 +1,1061 @@
1
+ /**
2
+ * MemoryService - Core service for filesystem-backed memory
3
+ *
4
+ * Manages indexing, searching, and CRUD operations on markdown files.
5
+ * Filesystem is the source of truth; SQLite is a derived index.
6
+ */
7
+ import { Context, Effect, Layer } from "effect";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { readFile, writeFile, readdir, stat, mkdir, rename, unlink } from "node:fs/promises";
10
+ import { join, relative, basename, extname, resolve } from "node:path";
11
+ import { MemoryDocumentRepository } from "../repo/memory-repo.js";
12
+ import { MemoryLinkRepository } from "../repo/memory-repo.js";
13
+ import { MemoryPropertyRepository } from "../repo/memory-repo.js";
14
+ import { MemorySourceRepository } from "../repo/memory-repo.js";
15
+ import { DatabaseError, MemoryDocumentNotFoundError, MemorySourceNotFoundError, ValidationError } from "../errors.js";
16
+ // Reserved frontmatter keys that are NOT synced as properties
17
+ const RESERVED_FRONTMATTER_KEYS = new Set(["tags", "related", "created"]);
18
+ /** Max recursion depth for findMarkdownFiles to prevent symlink cycles / stack overflow. */
19
+ const MAX_DIRECTORY_DEPTH = 50;
20
+ /**
21
+ * Generate a deterministic memory document ID from the relative file path.
22
+ * Uses 12 hex chars (48 bits) — birthday collision threshold at ~4M documents.
23
+ * (Prior 8-char version had ~50% collision probability at only ~65K documents.)
24
+ */
25
+ const generateDocId = (relativePath, rootDir) => {
26
+ const hash = createHash("sha256").update(`${rootDir}:${relativePath}`).digest("hex").slice(0, 12);
27
+ return `mem-${hash}`;
28
+ };
29
+ /**
30
+ * Slugify a title for use as a filename.
31
+ */
32
+ const slugify = (title) => {
33
+ return title
34
+ .toLowerCase()
35
+ .replace(/[^\p{L}\p{N}\s-]/gu, "") // Unicode-aware: keep letters, numbers, whitespace, hyphens
36
+ .replace(/\s+/g, "-")
37
+ .replace(/-+/g, "-")
38
+ .replace(/^-|-$/g, "")
39
+ .slice(0, 80);
40
+ };
41
+ /**
42
+ * Quote a YAML value if it contains characters that would break parsing.
43
+ */
44
+ /** YAML reserved words that must be quoted to preserve string type */
45
+ const YAML_RESERVED_WORDS = new Set([
46
+ "null", "Null", "NULL", "~",
47
+ "true", "True", "TRUE", "false", "False", "FALSE",
48
+ "yes", "Yes", "YES", "no", "No", "NO",
49
+ "on", "On", "ON", "off", "Off", "OFF",
50
+ ]);
51
+ const yamlQuote = (value) => {
52
+ if (YAML_RESERVED_WORDS.has(value) ||
53
+ /^[-+]?\d/.test(value) ||
54
+ /[:#[\]{}|>&*!'"?%@`,\n\r\t\0]/.test(value) ||
55
+ value.startsWith(" ") || value.endsWith(" ") || value === "") {
56
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\0/g, "\\0")}"`;
57
+ }
58
+ return value;
59
+ };
60
+ /**
61
+ * Quote a YAML array item if it contains commas or special characters.
62
+ * Reuses YAML_RESERVED_WORDS from above for boolean/null interop safety.
63
+ */
64
+ const yamlQuoteItem = (item) => {
65
+ if (item.includes(",") || item.includes('"') || item.includes("'") ||
66
+ item.includes("[") || item.includes("]") ||
67
+ item.includes("{") || item.includes("}") ||
68
+ item.includes("|") || item.includes(">") ||
69
+ item.includes("&") || item.includes("*") ||
70
+ item.includes("!") || item.includes("?") ||
71
+ item.includes("%") || item.includes("@") || item.includes("`") ||
72
+ item.includes("\n") || item.includes("\r") || item.includes("\t") || item.includes("\0") ||
73
+ item.includes("\\") || item === "" ||
74
+ item.includes(":") || item.includes("#") ||
75
+ item.startsWith(" ") || item.endsWith(" ") ||
76
+ YAML_RESERVED_WORDS.has(item) ||
77
+ /^[-+]?[0-9]/.test(item)) {
78
+ return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\0/g, "\\0")}"`;
79
+ }
80
+ return item;
81
+ };
82
+ /**
83
+ * Parse YAML frontmatter from markdown content.
84
+ * Returns { frontmatter, body, parsed } where frontmatter is the raw YAML string,
85
+ * body is the remaining content, and parsed is the structured object.
86
+ */
87
+ const parseFrontmatter = (content) => {
88
+ // Strip UTF-8 BOM if present (editors like VS Code can add this)
89
+ const cleaned = content.startsWith("\uFEFF") ? content.slice(1) : content;
90
+ // Must start with --- on the first line
91
+ if (!cleaned.startsWith("---\n") && !cleaned.startsWith("---\r\n")) {
92
+ return { frontmatter: null, body: content, parsed: null };
93
+ }
94
+ // Find the closing --- delimiter: must be on its own line immediately after the opening.
95
+ // Uses a line-by-line scan instead of a lazy regex to avoid truncating body content
96
+ // at in-body horizontal rules (---).
97
+ const openLen = cleaned.startsWith("---\r\n") ? 5 : 4;
98
+ const rest = cleaned.slice(openLen);
99
+ const restLines = rest.split(/\r?\n/);
100
+ let closingIdx = -1;
101
+ let charsConsumed = 0;
102
+ for (let i = 0; i < restLines.length; i++) {
103
+ if (restLines[i].trimEnd() === "---") {
104
+ closingIdx = i;
105
+ break;
106
+ }
107
+ charsConsumed += restLines[i].length + (rest[charsConsumed + restLines[i].length] === "\r" ? 2 : 1);
108
+ }
109
+ if (closingIdx === -1)
110
+ return { frontmatter: null, body: content, parsed: null };
111
+ const yamlStr = restLines.slice(0, closingIdx).join("\n");
112
+ // Body starts after the closing --- and its line ending
113
+ const closingLineLen = restLines[closingIdx].length;
114
+ const closingEnd = charsConsumed + closingLineLen;
115
+ // Skip the newline after closing --- (if present)
116
+ let bodyStart = openLen + closingEnd;
117
+ if (cleaned[bodyStart] === "\r")
118
+ bodyStart++;
119
+ if (cleaned[bodyStart] === "\n")
120
+ bodyStart++;
121
+ const body = cleaned.slice(bodyStart);
122
+ // Simple YAML parser for flat key-value + arrays + block scalars
123
+ const parsed = {};
124
+ const lines = yamlStr.split(/\r?\n/);
125
+ let currentKey = null;
126
+ // Block scalar tracking (|, >, |-, >-, |+, >+)
127
+ let currentBlockKey = null;
128
+ let currentBlockIndent = -1;
129
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
130
+ const line = lines[lineIdx];
131
+ // Block scalar continuation: collect indented or empty lines
132
+ if (currentBlockKey !== null) {
133
+ const indentMatch = line.match(/^(\s+)/);
134
+ if (indentMatch || line.trim() === "") {
135
+ const indent = indentMatch ? indentMatch[1].length : 0;
136
+ if (currentBlockIndent < 0 && indent > 0)
137
+ currentBlockIndent = indent;
138
+ const stripped = currentBlockIndent > 0 ? line.slice(currentBlockIndent) : line;
139
+ const existing = parsed[currentBlockKey];
140
+ parsed[currentBlockKey] = existing ? existing + "\n" + stripped : stripped;
141
+ continue;
142
+ }
143
+ else {
144
+ // Non-indented line ends the block scalar — fall through to normal parsing
145
+ currentBlockKey = null;
146
+ currentBlockIndent = -1;
147
+ }
148
+ }
149
+ const kvMatch = line.match(/^(\w[\w.-]*)\s*:\s*(.*)$/);
150
+ if (kvMatch) {
151
+ const key = kvMatch[1];
152
+ let value = kvMatch[2].trim();
153
+ // Strip inline YAML comments from unquoted values (e.g., "active # was draft" → "active")
154
+ if (typeof value === "string" && !value.startsWith('"') && !value.startsWith("'") && !value.startsWith("[")) {
155
+ value = value.replace(/\s+#.*$/, "");
156
+ }
157
+ // Handle inline array: [a, b, c]
158
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
159
+ const inner = value.slice(1, -1);
160
+ if (inner.trim() === "") {
161
+ value = [];
162
+ }
163
+ else {
164
+ // Parse items, respecting quoted strings and escape sequences
165
+ const items = [];
166
+ let current = "";
167
+ let inQuotes = false;
168
+ let quoteChar = "";
169
+ let wasQuoted = false;
170
+ for (let i = 0; i < inner.length; i++) {
171
+ const ch = inner[i];
172
+ // Handle escape sequences inside double-quoted strings
173
+ if (inQuotes && quoteChar === '"' && ch === "\\" && i + 1 < inner.length) {
174
+ const next = inner[i + 1];
175
+ switch (next) {
176
+ case "n":
177
+ current += "\n";
178
+ break;
179
+ case "r":
180
+ current += "\r";
181
+ break;
182
+ case "t":
183
+ current += "\t";
184
+ break;
185
+ case "0":
186
+ current += "\0";
187
+ break;
188
+ case '"':
189
+ current += '"';
190
+ break;
191
+ case "\\":
192
+ current += "\\";
193
+ break;
194
+ default:
195
+ current += ch + next;
196
+ break;
197
+ }
198
+ i++; // skip next character
199
+ }
200
+ else if (!inQuotes && (ch === '"' || ch === "'")) {
201
+ inQuotes = true;
202
+ quoteChar = ch;
203
+ wasQuoted = true;
204
+ // Discard inter-item whitespace before the opening quote
205
+ // e.g., in `[a, "b"]` the space before `"b"` is separator, not value content
206
+ // Only clear if current is pure whitespace (no real content accumulated)
207
+ if (current.trim() === "")
208
+ current = "";
209
+ }
210
+ else if (inQuotes && ch === quoteChar) {
211
+ // Handle '' escape inside single-quoted strings (YAML spec: '' → ')
212
+ if (quoteChar === "'" && i + 1 < inner.length && inner[i + 1] === "'") {
213
+ current += "'";
214
+ i++; // skip the second quote
215
+ continue;
216
+ }
217
+ inQuotes = false;
218
+ }
219
+ else if (!inQuotes && ch === ",") {
220
+ // Push item: preserve empty strings that were explicitly quoted (e.g. "")
221
+ // When quoted, preserve exact content (don't trim spaces inside quotes)
222
+ if (wasQuoted) {
223
+ items.push({ text: current, quoted: true });
224
+ }
225
+ else if (current.trim()) {
226
+ items.push({ text: current.trim(), quoted: false });
227
+ }
228
+ current = "";
229
+ wasQuoted = false;
230
+ }
231
+ else {
232
+ // Don't accumulate post-quote whitespace (separator, not content)
233
+ // e.g., ["hello" , "world"] — the space after closing quote is not part of the value
234
+ if (wasQuoted && !inQuotes && (ch === " " || ch === "\t")) {
235
+ continue;
236
+ }
237
+ // Reset wasQuoted if non-whitespace appears after closing quote (malformed input)
238
+ // This prevents corrupting the quoted/unquoted type metadata for coercion
239
+ if (wasQuoted && !inQuotes) {
240
+ wasQuoted = false;
241
+ }
242
+ current += ch;
243
+ }
244
+ }
245
+ // Push final item: preserve empty strings that were explicitly quoted
246
+ if (wasQuoted) {
247
+ items.push({ text: current, quoted: true });
248
+ }
249
+ else if (current.trim()) {
250
+ items.push({ text: current.trim(), quoted: false });
251
+ }
252
+ // Coerce ONLY unquoted array items to native types (same rules as scalar values).
253
+ // Quoted items (e.g., "42", "true") are kept as strings — quoting is intentional.
254
+ value = items.map((item) => {
255
+ if (item.quoted)
256
+ return item.text;
257
+ const t = item.text;
258
+ if (/^(null|Null|NULL|~)$/.test(t))
259
+ return null;
260
+ if (/^(true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$/.test(t)) {
261
+ const lower = t.toLowerCase();
262
+ return (lower === "true" || lower === "yes" || lower === "on");
263
+ }
264
+ if (/^-?\d+$/.test(t))
265
+ return parseInt(t, 10);
266
+ if (/^-?\d+\.\d+$/.test(t))
267
+ return parseFloat(t);
268
+ return t;
269
+ });
270
+ }
271
+ }
272
+ // Handle quoted string value (decode escape sequences for double-quoted strings)
273
+ // Order matters: protect literal backslashes first to avoid double-decode
274
+ // e.g. "\\n" (literal backslash + n) must not become a newline
275
+ else if (typeof value === "string" && value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
276
+ // Single-pass decode: match all escape sequences at once
277
+ value = value.slice(1, -1).replace(/\\([\\"nrt0])/g, (_match, ch) => {
278
+ switch (ch) {
279
+ case "n": return "\n";
280
+ case "r": return "\r";
281
+ case "t": return "\t";
282
+ case "0": return "\0";
283
+ case '"': return '"';
284
+ case "\\": return "\\";
285
+ default: return ch;
286
+ }
287
+ });
288
+ }
289
+ // Handle single-quoted string value (no escape processing per YAML spec, but '' → ' unescape)
290
+ else if (typeof value === "string" && value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
291
+ value = value.slice(1, -1).replace(/''/g, "'");
292
+ }
293
+ // Handle bare YAML null — preserve native type so round-trip doesn't quote as "null"
294
+ else if (typeof value === "string" && /^(null|Null|NULL|~)$/.test(value)) {
295
+ value = null;
296
+ }
297
+ // Handle bare YAML booleans — preserve native type so round-trip doesn't quote them
298
+ else if (typeof value === "string" && /^(true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$/.test(value)) {
299
+ const lower = value.toLowerCase();
300
+ value = (lower === "true" || lower === "yes" || lower === "on");
301
+ }
302
+ // Handle bare integers — preserve native type so round-trip doesn't quote them
303
+ else if (typeof value === "string" && /^-?\d+$/.test(value)) {
304
+ value = parseInt(value, 10);
305
+ }
306
+ // Handle bare floats — preserve native type
307
+ else if (typeof value === "string" && /^-?\d+\.\d+$/.test(value)) {
308
+ value = parseFloat(value);
309
+ }
310
+ // Handle YAML block scalar indicators (|, >, |-, >-, |+, >+)
311
+ else if (typeof value === "string" && /^[|>][+-]?$/.test(value.trim())) {
312
+ currentBlockKey = key;
313
+ currentBlockIndent = -1;
314
+ parsed[key] = "";
315
+ currentKey = null;
316
+ continue;
317
+ }
318
+ // Handle empty value: could be a block array (items follow) or bare null.
319
+ // Peek at the next non-blank line to decide: if it starts with "- ", treat as array.
320
+ // Otherwise treat as null (YAML spec: bare `key:` with no value is null).
321
+ else if (value === "") {
322
+ // Peek ahead: find next non-blank, non-comment line
323
+ let nextLine;
324
+ for (let j = lineIdx + 1; j < lines.length; j++) {
325
+ const trimmed = lines[j].trim();
326
+ if (trimmed !== "" && !trimmed.startsWith("#")) {
327
+ nextLine = lines[j];
328
+ break;
329
+ }
330
+ }
331
+ if (nextLine && /^\s*-(\s|$)/.test(nextLine)) {
332
+ // Next content line is a block array item → treat as array
333
+ value = [];
334
+ currentKey = key;
335
+ parsed[key] = value;
336
+ continue;
337
+ }
338
+ // No array items follow → bare null value
339
+ value = null;
340
+ parsed[key] = value;
341
+ currentKey = null;
342
+ continue;
343
+ }
344
+ parsed[key] = value;
345
+ currentKey = null;
346
+ }
347
+ else if (currentKey && line.match(/^\s*-\s+(.+)$/)) {
348
+ // Array item
349
+ const itemMatch = line.match(/^\s*-\s+(.+)$/);
350
+ if (itemMatch && Array.isArray(parsed[currentKey])) {
351
+ let itemVal = itemMatch[1].trim();
352
+ // Only strip MATCHED quote pairs (not unmatched leading/trailing quotes)
353
+ if (typeof itemVal === "string" && itemVal.length >= 2) {
354
+ if ((itemVal.startsWith('"') && itemVal.endsWith('"')) ||
355
+ (itemVal.startsWith("'") && itemVal.endsWith("'"))) {
356
+ const wasDoubleQuoted = itemVal.startsWith('"');
357
+ itemVal = itemVal.slice(1, -1);
358
+ // Decode escape sequences for double-quoted items
359
+ if (wasDoubleQuoted) {
360
+ itemVal = itemVal.replace(/\\([\\"nrt0])/g, (_m, ch) => {
361
+ switch (ch) {
362
+ case "n": return "\n";
363
+ case "r": return "\r";
364
+ case "t": return "\t";
365
+ case "0": return "\0";
366
+ case '"': return '"';
367
+ case "\\": return "\\";
368
+ default: return ch;
369
+ }
370
+ });
371
+ }
372
+ else {
373
+ // Single-quoted: '' → ' unescape
374
+ itemVal = itemVal.replace(/''/g, "'");
375
+ }
376
+ }
377
+ }
378
+ // Type-coerce unquoted block array items (consistent with inline arrays)
379
+ if (typeof itemVal === "string") {
380
+ const t = itemVal;
381
+ const isQuoted = itemMatch[1].trim().startsWith('"') || itemMatch[1].trim().startsWith("'");
382
+ if (!isQuoted) {
383
+ if (/^(null|Null|NULL|~)$/.test(t))
384
+ itemVal = null;
385
+ else if (/^(true|True|TRUE|false|False|FALSE|yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$/.test(t)) {
386
+ const lower = t.toLowerCase();
387
+ itemVal = (lower === "true" || lower === "yes" || lower === "on");
388
+ }
389
+ else if (/^-?\d+$/.test(t))
390
+ itemVal = parseInt(t, 10);
391
+ else if (/^-?\d+\.\d+$/.test(t))
392
+ itemVal = parseFloat(t);
393
+ }
394
+ }
395
+ ;
396
+ parsed[currentKey].push(itemVal);
397
+ }
398
+ }
399
+ else if (currentKey && line.match(/^\s*-\s*$/)) {
400
+ // Empty array item (bare "- " or "-") → null (YAML spec: empty sequence entry is null)
401
+ if (Array.isArray(parsed[currentKey])) {
402
+ ;
403
+ parsed[currentKey].push(null);
404
+ }
405
+ }
406
+ }
407
+ return { frontmatter: yamlStr, body, parsed };
408
+ };
409
+ /**
410
+ * Extract the title from markdown content: first H1 heading or filename.
411
+ */
412
+ const extractTitle = (content, filename) => {
413
+ const match = content.match(/^#\s+(.+)$/m);
414
+ if (match) {
415
+ return match[1].trim()
416
+ // Strip wikilinks: [[page|alias]] → alias, [[page]] → page
417
+ .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, "$2")
418
+ .replace(/\[\[([^\]]+)\]\]/g, "$1")
419
+ // Strip markdown links: [text](url) → text
420
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
421
+ // Strip inline formatting: **bold**, *italic*, `code`, __underline__, _italic_, ~~strike~~
422
+ .replace(/(\*{1,2}|_{1,2}|`|~~)(.+?)\1/g, "$2")
423
+ .trim();
424
+ }
425
+ return basename(filename, extname(filename));
426
+ };
427
+ /**
428
+ * Parse wikilinks from markdown body: [[page]] or [[page|alias]]
429
+ * Strips #heading fragments so link resolution works against file paths.
430
+ * Strips fenced code blocks and inline code first to avoid phantom links.
431
+ */
432
+ const parseWikilinks = (body) => {
433
+ // Strip fenced code blocks (```...```, ````...````, etc.) and inline code (`...`) to avoid phantom links
434
+ const stripped = body
435
+ .replace(/`{3,}[\s\S]*?`{3,}/g, "")
436
+ .replace(/`[^`\n]+`/g, "");
437
+ const links = [];
438
+ const regex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
439
+ let match;
440
+ while ((match = regex.exec(stripped)) !== null) {
441
+ // Strip #heading fragment for link resolution
442
+ const ref = match[1].trim().replace(/#.*$/, "").trim();
443
+ if (ref.length > 0) {
444
+ links.push(ref);
445
+ }
446
+ }
447
+ return links;
448
+ };
449
+ /**
450
+ * Recursively find all .md files in a directory.
451
+ * Includes depth limit and symlink guard to prevent infinite recursion.
452
+ */
453
+ const findMarkdownFiles = async (dir, depth = 0) => {
454
+ if (depth > MAX_DIRECTORY_DEPTH)
455
+ return [];
456
+ const files = [];
457
+ const entries = await readdir(dir, { withFileTypes: true });
458
+ for (const entry of entries) {
459
+ // Skip symlinks entirely to prevent cycles
460
+ if (entry.isSymbolicLink())
461
+ continue;
462
+ const fullPath = join(dir, entry.name);
463
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
464
+ files.push(...await findMarkdownFiles(fullPath, depth + 1));
465
+ }
466
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
467
+ files.push(fullPath);
468
+ }
469
+ }
470
+ return files;
471
+ };
472
+ /**
473
+ * Serialize frontmatter to YAML string with proper quoting.
474
+ */
475
+ const serializeFrontmatter = (data) => {
476
+ const lines = [];
477
+ for (const [key, value] of Object.entries(data)) {
478
+ // Skip undefined values (prevents serializing as literal "undefined")
479
+ if (value === undefined)
480
+ continue;
481
+ if (Array.isArray(value)) {
482
+ // Filter only undefined; preserve null items as bare `null` keyword
483
+ const safe = value.filter(v => v !== undefined);
484
+ if (safe.length === 0) {
485
+ lines.push(`${key}: []`);
486
+ }
487
+ else {
488
+ lines.push(`${key}: [${safe.map(v => v === null ? "null" :
489
+ (typeof v === "boolean" || typeof v === "number") ? String(v) : yamlQuoteItem(String(v))).join(", ")}]`);
490
+ }
491
+ }
492
+ else if (value === null) {
493
+ // Emit bare null to preserve YAML semantics on round-trip (not quoted "null")
494
+ lines.push(`${key}: null`);
495
+ }
496
+ else if (typeof value === "boolean" || typeof value === "number") {
497
+ // Emit native booleans/numbers bare (no quoting) to preserve YAML semantics on round-trip
498
+ lines.push(`${key}: ${String(value)}`);
499
+ }
500
+ else if (typeof value === "object") {
501
+ // Serialize nested objects as JSON to prevent "[object Object]" corruption
502
+ lines.push(`${key}: ${yamlQuote(JSON.stringify(value))}`);
503
+ }
504
+ else {
505
+ lines.push(`${key}: ${yamlQuote(String(value))}`);
506
+ }
507
+ }
508
+ return lines.join("\n");
509
+ };
510
+ /**
511
+ * Validate that a resolved path is contained within the given root directory.
512
+ * Prevents path traversal attacks.
513
+ */
514
+ const assertPathContainment = (filePath, rootDir) => {
515
+ const resolvedFile = resolve(filePath);
516
+ const resolvedRoot = resolve(rootDir);
517
+ return resolvedFile.startsWith(resolvedRoot + "/") || resolvedFile === resolvedRoot;
518
+ };
519
+ /**
520
+ * Atomically write a file by writing to a temp file then renaming.
521
+ * POSIX rename() is atomic on the same filesystem, preventing partial writes
522
+ * from corrupting the file on crash.
523
+ */
524
+ const atomicWriteFile = async (filePath, content) => {
525
+ const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.tmp`;
526
+ await writeFile(tmpPath, content, "utf-8");
527
+ try {
528
+ await rename(tmpPath, filePath);
529
+ }
530
+ catch (err) {
531
+ // Clean up orphaned temp file on rename failure
532
+ await unlink(tmpPath).catch(() => { });
533
+ throw err;
534
+ }
535
+ };
536
+ // =============================================================================
537
+ // MemoryService
538
+ // =============================================================================
539
+ export class MemoryService extends Context.Tag("MemoryService")() {
540
+ }
541
+ export const MemoryServiceLive = Layer.effect(MemoryService, Effect.gen(function* () {
542
+ const docRepo = yield* MemoryDocumentRepository;
543
+ const linkRepo = yield* MemoryLinkRepository;
544
+ const propRepo = yield* MemoryPropertyRepository;
545
+ const sourceRepo = yield* MemorySourceRepository;
546
+ /**
547
+ * Validate that a file path is safely within its root directory.
548
+ */
549
+ const validateFilePath = (filePath, rootDir) => Effect.gen(function* () {
550
+ if (!assertPathContainment(filePath, rootDir)) {
551
+ yield* Effect.fail(new ValidationError({ reason: `Path "${filePath}" escapes root directory "${rootDir}"` }));
552
+ }
553
+ });
554
+ /**
555
+ * Index a single markdown file. Accepts optional cached content to avoid double reads.
556
+ *
557
+ * CRASH SAFETY: Uses a two-phase hash write pattern. The document is initially upserted
558
+ * with fileHash="" (sentinel). Links and properties are written next. Only after all
559
+ * three steps complete is the real hash set via updateFileHash(). If a crash occurs
560
+ * between steps, incremental mode sees hash="" ≠ real hash → re-indexes the file.
561
+ * This prevents the stale-hash bug where a partially-indexed file is permanently
562
+ * skipped by incremental indexing.
563
+ */
564
+ /** Maximum file size to index (10MB). Larger files are skipped to prevent OOM. */
565
+ const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
566
+ const indexFile = (filePath, rootDir, cachedContent) => Effect.gen(function* () {
567
+ // Path containment check: skip files that escape the root directory
568
+ if (!assertPathContainment(filePath, rootDir)) {
569
+ return false;
570
+ }
571
+ // Check file size BEFORE reading to prevent OOM on very large files
572
+ const fileStat = yield* Effect.tryPromise({
573
+ try: () => stat(filePath),
574
+ catch: (cause) => new DatabaseError({ cause })
575
+ });
576
+ if (fileStat.size > MAX_FILE_SIZE_BYTES) {
577
+ return false; // Skip oversized files
578
+ }
579
+ const content = cachedContent ?? (yield* Effect.tryPromise({
580
+ try: () => readFile(filePath, "utf-8"),
581
+ catch: (cause) => new DatabaseError({ cause })
582
+ }));
583
+ // Guard: skip binary files (null byte in first 8KB is a definitive signal)
584
+ if (content.slice(0, 8192).includes("\0")) {
585
+ return false;
586
+ }
587
+ const relativePath = relative(rootDir, filePath);
588
+ const fileHash = createHash("sha256").update(content).digest("hex");
589
+ const docId = generateDocId(relativePath, rootDir);
590
+ const { body, parsed: parsedFm } = parseFrontmatter(content);
591
+ const title = extractTitle(body || content, basename(filePath));
592
+ // Coerce tag/related items to strings: block array coercion may produce booleans/numbers/null
593
+ const tags = parsedFm && Array.isArray(parsedFm.tags)
594
+ ? parsedFm.tags.filter(t => t != null).map(t => String(t))
595
+ : [];
596
+ const related = parsedFm && Array.isArray(parsedFm.related)
597
+ ? parsedFm.related.filter(t => t != null).map(t => String(t))
598
+ : [];
599
+ const now = new Date().toISOString();
600
+ const createdAt = (parsedFm && typeof parsedFm.created === "string") ? parsedFm.created : fileStat.mtime.toISOString();
601
+ // Phase 1: Upsert with empty hash sentinel (marks "indexing in progress")
602
+ yield* docRepo.upsertDocument({
603
+ id: docId,
604
+ filePath: relativePath,
605
+ rootDir,
606
+ title,
607
+ content,
608
+ frontmatter: parsedFm ? JSON.stringify(parsedFm) : null,
609
+ tags: tags.length > 0 ? JSON.stringify(tags) : null,
610
+ fileHash: "",
611
+ fileMtime: fileStat.mtime.toISOString(),
612
+ createdAt,
613
+ indexedAt: now,
614
+ });
615
+ // Phase 2: Parse and store links (use body, not full content, to avoid wikilinks inside frontmatter/code blocks)
616
+ yield* linkRepo.deleteBySource(docId);
617
+ const wikilinks = parseWikilinks(body || content);
618
+ const allLinks = [
619
+ ...wikilinks.map(ref => ({ sourceDocId: docId, targetRef: ref, linkType: "wikilink" })),
620
+ ...related.map(ref => ({ sourceDocId: docId, targetRef: ref, linkType: "frontmatter" })),
621
+ ];
622
+ if (allLinks.length > 0) {
623
+ yield* linkRepo.insertLinks(allLinks);
624
+ }
625
+ // Sync properties from frontmatter (non-reserved keys).
626
+ // Always call syncFromFrontmatter even when parsedFm is null to clear stale properties.
627
+ // Stringify non-string primitives (booleans, numbers) so they're queryable via --prop.
628
+ const properties = {};
629
+ if (parsedFm) {
630
+ for (const [key, value] of Object.entries(parsedFm)) {
631
+ if (RESERVED_FRONTMATTER_KEYS.has(key))
632
+ continue;
633
+ if (value === null || value === undefined)
634
+ continue;
635
+ if (Array.isArray(value) || typeof value === "object")
636
+ continue;
637
+ properties[key] = String(value);
638
+ }
639
+ }
640
+ yield* propRepo.syncFromFrontmatter(docId, properties);
641
+ // Phase 3: Set real hash — only after links + properties are fully written.
642
+ // Incremental mode checks this hash; empty sentinel ≠ real hash → re-index.
643
+ yield* docRepo.updateFileHash(docId, fileHash);
644
+ return true;
645
+ });
646
+ return {
647
+ addSource: (dir, label) => Effect.gen(function* () {
648
+ const absDir = resolve(dir);
649
+ // Verify directory exists before registering
650
+ const dirStat = yield* Effect.tryPromise({
651
+ try: () => stat(absDir),
652
+ catch: () => new ValidationError({ reason: `Directory does not exist: ${absDir}` })
653
+ });
654
+ if (!dirStat.isDirectory()) {
655
+ return yield* Effect.fail(new ValidationError({ reason: `Not a directory: ${absDir}` }));
656
+ }
657
+ return yield* sourceRepo.addSource(absDir, label);
658
+ }),
659
+ removeSource: (dir) => Effect.gen(function* () {
660
+ const absDir = resolve(dir);
661
+ const source = yield* sourceRepo.findSource(absDir);
662
+ if (!source) {
663
+ yield* Effect.fail(new MemorySourceNotFoundError({ rootDir: absDir }));
664
+ }
665
+ // Atomic: nulls incoming links → deletes docs → deletes source in one transaction
666
+ yield* sourceRepo.removeSource(absDir);
667
+ }),
668
+ listSources: () => sourceRepo.listSources(),
669
+ createDocument: (input) => Effect.gen(function* () {
670
+ // Validate title produces a non-empty slug
671
+ const slug = slugify(input.title);
672
+ if (slug.length === 0) {
673
+ return yield* Effect.fail(new ValidationError({
674
+ reason: `Title "${input.title}" produces an empty filename after slugification`
675
+ }));
676
+ }
677
+ // Determine target directory
678
+ const sources = yield* sourceRepo.listSources();
679
+ const targetDir = input.dir ? resolve(input.dir) : (sources[0]?.rootDir ?? resolve(".tx", "memory"));
680
+ // Resolve rootDir: find the registered source that contains targetDir
681
+ // (important when targetDir is a subdirectory of a source)
682
+ let matchingSource = sources.find(s => targetDir.startsWith(s.rootDir + "/") || targetDir === s.rootDir);
683
+ // Validate target directory is within a registered source (if dir was explicitly provided)
684
+ if (input.dir && !matchingSource) {
685
+ return yield* Effect.fail(new ValidationError({
686
+ reason: `Directory "${targetDir}" is not within any registered memory source`
687
+ }));
688
+ }
689
+ // Auto-register fallback directory as a source so documents survive future index() runs
690
+ // (without this, docs created in an unregistered dir become ghosts)
691
+ if (!matchingSource) {
692
+ matchingSource = yield* sourceRepo.addSource(targetDir, "auto");
693
+ }
694
+ // Use the matching source's rootDir for proper relative path calculation
695
+ const rootDir = matchingSource.rootDir;
696
+ // Ensure directory exists
697
+ yield* Effect.tryPromise({
698
+ try: () => mkdir(targetDir, { recursive: true }),
699
+ catch: (cause) => new DatabaseError({ cause })
700
+ });
701
+ // Generate filename
702
+ const filename = `${slug}.md`;
703
+ const filePath = join(targetDir, filename);
704
+ // Check if file already exists to prevent silent overwrite
705
+ const fileExists = yield* Effect.tryPromise({
706
+ try: async () => {
707
+ try {
708
+ await stat(filePath);
709
+ return true;
710
+ }
711
+ catch {
712
+ return false;
713
+ }
714
+ },
715
+ catch: (cause) => new DatabaseError({ cause })
716
+ });
717
+ if (fileExists) {
718
+ return yield* Effect.fail(new ValidationError({
719
+ reason: `File already exists: ${filePath}`
720
+ }));
721
+ }
722
+ // Build content with frontmatter
723
+ const fmData = {};
724
+ if (input.tags && input.tags.length > 0)
725
+ fmData.tags = [...input.tags];
726
+ fmData.created = new Date().toISOString();
727
+ if (input.properties) {
728
+ for (const [key, value] of Object.entries(input.properties)) {
729
+ if (RESERVED_FRONTMATTER_KEYS.has(key)) {
730
+ return yield* Effect.fail(new ValidationError({
731
+ reason: `Property key "${key}" is reserved; use the tags/content fields instead`
732
+ }));
733
+ }
734
+ fmData[key] = value;
735
+ }
736
+ }
737
+ let fileContent = "";
738
+ if (Object.keys(fmData).length > 0) {
739
+ fileContent += `---\n${serializeFrontmatter(fmData)}\n---\n\n`;
740
+ }
741
+ fileContent += `# ${input.title}\n\n${input.content ?? ""}\n`;
742
+ // Write file atomically (temp + rename prevents partial writes on crash)
743
+ yield* Effect.tryPromise({
744
+ try: () => atomicWriteFile(filePath, fileContent),
745
+ catch: (cause) => new DatabaseError({ cause })
746
+ });
747
+ // Index the new file using the resolved rootDir
748
+ yield* indexFile(filePath, rootDir, fileContent);
749
+ // Return the indexed document
750
+ const relativePath = relative(rootDir, filePath);
751
+ const docId = generateDocId(relativePath, rootDir);
752
+ const doc = yield* docRepo.findById(docId);
753
+ if (!doc) {
754
+ return yield* Effect.fail(new DatabaseError({ cause: new Error("Document not found after indexing") }));
755
+ }
756
+ return doc;
757
+ }),
758
+ updateFrontmatter: (id, updates) => Effect.gen(function* () {
759
+ const doc = yield* docRepo.findById(id);
760
+ if (!doc) {
761
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id }));
762
+ }
763
+ const filePath = join(doc.rootDir, doc.filePath);
764
+ yield* validateFilePath(filePath, doc.rootDir);
765
+ const content = yield* Effect.tryPromise({
766
+ try: () => readFile(filePath, "utf-8"),
767
+ catch: (cause) => new DatabaseError({ cause })
768
+ });
769
+ const { parsed: parsedFm, body } = parseFrontmatter(content);
770
+ const fm = parsedFm ?? {};
771
+ // Update tags: coerce to strings (block array coercion may produce booleans/numbers)
772
+ // and filter empty/whitespace-only tags to prevent "" entries in frontmatter
773
+ let tags = Array.isArray(fm.tags)
774
+ ? fm.tags.filter(t => t != null).map(t => String(t))
775
+ : [];
776
+ if (updates.addTags) {
777
+ for (const tag of updates.addTags) {
778
+ if (tag.trim().length === 0)
779
+ continue;
780
+ if (!tags.includes(tag))
781
+ tags.push(tag);
782
+ }
783
+ }
784
+ if (updates.removeTags) {
785
+ tags = tags.filter((t) => !updates.removeTags.includes(t));
786
+ }
787
+ fm.tags = tags;
788
+ // Update related
789
+ if (updates.addRelated) {
790
+ const related = Array.isArray(fm.related) ? [...fm.related] : [];
791
+ for (const ref of updates.addRelated) {
792
+ if (!related.includes(ref))
793
+ related.push(ref);
794
+ }
795
+ fm.related = related;
796
+ }
797
+ // Rewrite file atomically
798
+ const newContent = `---\n${serializeFrontmatter(fm)}\n---\n${body}`;
799
+ yield* Effect.tryPromise({
800
+ try: () => atomicWriteFile(filePath, newContent),
801
+ catch: (cause) => new DatabaseError({ cause })
802
+ });
803
+ // Re-index
804
+ yield* indexFile(filePath, doc.rootDir, newContent);
805
+ const updated = yield* docRepo.findById(id);
806
+ if (!updated) {
807
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id }));
808
+ }
809
+ return updated;
810
+ }),
811
+ setProperty: (id, key, value) => Effect.gen(function* () {
812
+ // Guard reserved frontmatter keys
813
+ if (RESERVED_FRONTMATTER_KEYS.has(key)) {
814
+ return yield* Effect.fail(new ValidationError({
815
+ reason: `Key "${key}" is reserved; use updateFrontmatter to modify tags/related/created`
816
+ }));
817
+ }
818
+ // Validate key format: must be a valid YAML bare key (letters, digits, dots, hyphens)
819
+ // Keys with colons, slashes, newlines etc. corrupt frontmatter on round-trip
820
+ if (!/^\w[\w.-]*$/.test(key)) {
821
+ return yield* Effect.fail(new ValidationError({
822
+ reason: `Property key "${key}" contains invalid characters. Keys must match [a-zA-Z0-9_][a-zA-Z0-9_.-]*`
823
+ }));
824
+ }
825
+ const doc = yield* docRepo.findById(id);
826
+ if (!doc) {
827
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id }));
828
+ }
829
+ // Write file first (filesystem is source of truth — if this fails, DB stays consistent)
830
+ const filePath = join(doc.rootDir, doc.filePath);
831
+ yield* validateFilePath(filePath, doc.rootDir);
832
+ const content = yield* Effect.tryPromise({
833
+ try: () => readFile(filePath, "utf-8"),
834
+ catch: (cause) => new DatabaseError({ cause })
835
+ });
836
+ const { parsed: parsedFm, body } = parseFrontmatter(content);
837
+ const hadFrontmatter = parsedFm !== null;
838
+ const fm = parsedFm ?? {};
839
+ fm[key] = value;
840
+ // When adding frontmatter to a file that had none, ensure blank line separator
841
+ const separator = hadFrontmatter ? "" : "\n";
842
+ const newContent = `---\n${serializeFrontmatter(fm)}\n---\n${separator}${body}`;
843
+ yield* Effect.tryPromise({
844
+ try: () => atomicWriteFile(filePath, newContent),
845
+ catch: (cause) => new DatabaseError({ cause })
846
+ });
847
+ // Re-index to keep DB hash/frontmatter/content in sync (same pattern as updateFrontmatter)
848
+ yield* indexFile(filePath, doc.rootDir, newContent);
849
+ }),
850
+ getProperties: (id) => propRepo.getProperties(id),
851
+ removeProperty: (id, key) => Effect.gen(function* () {
852
+ // Guard reserved frontmatter keys
853
+ if (RESERVED_FRONTMATTER_KEYS.has(key)) {
854
+ return yield* Effect.fail(new ValidationError({
855
+ reason: `Key "${key}" is reserved; use updateFrontmatter to modify tags/related/created`
856
+ }));
857
+ }
858
+ const doc = yield* docRepo.findById(id);
859
+ if (!doc) {
860
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id }));
861
+ }
862
+ // Write file first (filesystem is source of truth — if this fails, DB stays consistent)
863
+ const filePath = join(doc.rootDir, doc.filePath);
864
+ yield* validateFilePath(filePath, doc.rootDir);
865
+ const content = yield* Effect.tryPromise({
866
+ try: () => readFile(filePath, "utf-8"),
867
+ catch: (cause) => new DatabaseError({ cause })
868
+ });
869
+ const { parsed: parsedFm, body } = parseFrontmatter(content);
870
+ if (parsedFm && key in parsedFm) {
871
+ delete parsedFm[key];
872
+ // Only write frontmatter block if there are remaining keys; otherwise write body only
873
+ const newContent = Object.keys(parsedFm).length > 0
874
+ ? `---\n${serializeFrontmatter(parsedFm)}\n---\n${body}`
875
+ : body;
876
+ yield* Effect.tryPromise({
877
+ try: () => atomicWriteFile(filePath, newContent),
878
+ catch: (cause) => new DatabaseError({ cause })
879
+ });
880
+ // Re-index to keep DB hash/frontmatter/content in sync
881
+ yield* indexFile(filePath, doc.rootDir, newContent);
882
+ }
883
+ else {
884
+ // Key not in frontmatter — just delete from DB if it exists
885
+ yield* propRepo.deleteProperty(id, key);
886
+ }
887
+ }),
888
+ index: (options) => Effect.gen(function* () {
889
+ const sources = yield* sourceRepo.listSources();
890
+ let indexed = 0;
891
+ let skipped = 0;
892
+ let removed = 0;
893
+ for (const source of sources) {
894
+ const existingPaths = new Set(yield* docRepo.listPathsByRootDir(source.rootDir));
895
+ // Find all .md files (gracefully handle deleted source directories)
896
+ const files = yield* Effect.tryPromise({
897
+ try: () => findMarkdownFiles(source.rootDir),
898
+ catch: (cause) => new DatabaseError({ cause })
899
+ }).pipe(Effect.catchAll(() => Effect.succeed([])));
900
+ for (const filePath of files) {
901
+ const relativePath = relative(source.rootDir, filePath);
902
+ // Wrap each file in catchAll so a single unreadable file
903
+ // (permission denied, encoding error, etc.) doesn't abort the entire run.
904
+ const fileResult = yield* Effect.gen(function* () {
905
+ if (options?.incremental) {
906
+ // Check file size BEFORE reading to prevent OOM on large files
907
+ const fileStat = yield* Effect.tryPromise({
908
+ try: () => stat(filePath),
909
+ catch: (cause) => new DatabaseError({ cause })
910
+ });
911
+ if (fileStat.size > 10 * 1024 * 1024) {
912
+ return "skipped"; // Skip oversized files (>10MB)
913
+ }
914
+ // Read file once for hash check; pass cached content to indexFile to avoid double read
915
+ const content = yield* Effect.tryPromise({
916
+ try: () => readFile(filePath, "utf-8"),
917
+ catch: (cause) => new DatabaseError({ cause })
918
+ });
919
+ const fileHash = createHash("sha256").update(content).digest("hex");
920
+ const existing = yield* docRepo.findByPath(relativePath, source.rootDir);
921
+ if (existing && existing.fileHash === fileHash) {
922
+ return "skipped";
923
+ }
924
+ // File changed — index with cached content (no double read)
925
+ yield* indexFile(filePath, source.rootDir, content);
926
+ }
927
+ else {
928
+ yield* indexFile(filePath, source.rootDir);
929
+ }
930
+ return "indexed";
931
+ }).pipe(Effect.catchAll(() => Effect.succeed("error")));
932
+ // Only remove from "needs cleanup" set on success — if file read failed
933
+ // (TOCTOU: deleted between listing and reading), keep the path in the set
934
+ // so its stale DB entry is cleaned up in the deletion pass below.
935
+ if (fileResult !== "error") {
936
+ existingPaths.delete(relativePath);
937
+ }
938
+ if (fileResult === "indexed")
939
+ indexed++;
940
+ else if (fileResult === "skipped")
941
+ skipped++;
942
+ // "error" — silently skip; stale DB entry will be cleaned up below
943
+ }
944
+ // Remove docs for deleted files
945
+ const deletedPaths = [...existingPaths];
946
+ if (deletedPaths.length > 0) {
947
+ const count = yield* docRepo.deleteByPaths(source.rootDir, deletedPaths);
948
+ removed += count;
949
+ }
950
+ }
951
+ // Resolve link targets
952
+ yield* linkRepo.resolveTargets();
953
+ return { indexed, skipped, removed };
954
+ }),
955
+ indexStatus: () => Effect.gen(function* () {
956
+ const sources = yield* sourceRepo.listSources();
957
+ const totalDocs = yield* docRepo.count();
958
+ const embedded = yield* docRepo.countWithEmbeddings();
959
+ const links = yield* linkRepo.count();
960
+ // Count total .md files across all sources (skip sources whose dirs were deleted)
961
+ // Also count files not yet in DB (new) and DB entries with no file on disk (orphaned)
962
+ let totalFiles = 0;
963
+ let notIndexed = 0;
964
+ for (const source of sources) {
965
+ const files = yield* Effect.tryPromise({
966
+ try: () => findMarkdownFiles(source.rootDir),
967
+ catch: (cause) => new DatabaseError({ cause })
968
+ }).pipe(Effect.catchAll(() => Effect.succeed([])));
969
+ totalFiles += files.length;
970
+ // Count files not yet in DB
971
+ const indexedPaths = new Set(yield* docRepo.listPathsByRootDir(source.rootDir));
972
+ for (const filePath of files) {
973
+ const rel = relative(source.rootDir, filePath);
974
+ if (!indexedPaths.has(rel))
975
+ notIndexed++;
976
+ }
977
+ }
978
+ return {
979
+ totalFiles,
980
+ indexed: totalDocs,
981
+ stale: notIndexed,
982
+ embedded,
983
+ links,
984
+ sources: sources.length,
985
+ };
986
+ }),
987
+ search: (query, options) => Effect.gen(function* () {
988
+ const limit = Math.max(1, options?.limit ?? 10);
989
+ const minScore = options?.minScore ?? 0;
990
+ // Fetch extra rows when minScore filtering is active to avoid undercounting
991
+ const fetchLimit = minScore > 0 ? limit * 3 : limit;
992
+ // BM25 search
993
+ let bm25Results = yield* docRepo.searchBM25(query, fetchLimit);
994
+ // Filter by tags if specified (case-insensitive)
995
+ if (options?.tags && options.tags.length > 0) {
996
+ const tagFilterLower = options.tags.map((t) => t.toLowerCase());
997
+ bm25Results = bm25Results.filter(r => tagFilterLower.every((t) => r.document.tags.some((rt) => rt.toLowerCase() === t)));
998
+ }
999
+ // Filter by properties if specified
1000
+ if (options?.props && options.props.length > 0) {
1001
+ for (const propFilter of options.props) {
1002
+ const eqIdx = propFilter.indexOf("=");
1003
+ const key = eqIdx >= 0 ? propFilter.slice(0, eqIdx) : propFilter;
1004
+ const value = eqIdx >= 0 ? propFilter.slice(eqIdx + 1) : undefined;
1005
+ if (!key || key.trim().length === 0)
1006
+ continue;
1007
+ const matchingDocIds = new Set(yield* propRepo.findByProperty(key.trim(), value));
1008
+ bm25Results = bm25Results.filter(r => matchingDocIds.has(r.document.id));
1009
+ }
1010
+ }
1011
+ // Recency scoring: 30-day decay (same weight as retriever for consistent scores)
1012
+ const RECENCY_WEIGHT = 0.1;
1013
+ const now = Date.now();
1014
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
1015
+ // Convert to scored results with recency blend matching retriever formula
1016
+ const results = bm25Results
1017
+ .map((r, rank) => {
1018
+ const mtimeMs = new Date(r.document.fileMtime).getTime();
1019
+ // Clamp future-dated files to now (prevents negative age / score > 1)
1020
+ const safeMs = isNaN(mtimeMs) ? now : mtimeMs;
1021
+ const ageMs = Math.max(0, now - Math.min(safeMs, now));
1022
+ const recencyScore = Math.max(0, 1.0 - (ageMs / THIRTY_DAYS_MS));
1023
+ // Blend BM25 + recency using same formula as retriever for consistent scores
1024
+ const relevanceScore = (1 - RECENCY_WEIGHT) * r.score + RECENCY_WEIGHT * recencyScore;
1025
+ return {
1026
+ ...r.document,
1027
+ relevanceScore,
1028
+ bm25Score: r.score,
1029
+ vectorScore: 0,
1030
+ rrfScore: 0, // No RRF fusion in BM25-only path; 0 indicates single-list mode
1031
+ recencyScore,
1032
+ bm25Rank: rank + 1,
1033
+ vectorRank: 0,
1034
+ };
1035
+ })
1036
+ .sort((a, b) => b.relevanceScore - a.relevanceScore)
1037
+ .filter(r => r.relevanceScore >= minScore)
1038
+ .slice(0, limit);
1039
+ return results;
1040
+ }),
1041
+ getDocument: (id) => Effect.gen(function* () {
1042
+ const doc = yield* docRepo.findById(id);
1043
+ if (!doc) {
1044
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id }));
1045
+ }
1046
+ return doc;
1047
+ }),
1048
+ getLinks: (id) => linkRepo.findOutgoing(id),
1049
+ getBacklinks: (id) => linkRepo.findIncoming(id),
1050
+ addLink: (sourceId, targetRef) => Effect.gen(function* () {
1051
+ // Validate source document exists to prevent phantom links
1052
+ const doc = yield* docRepo.findById(sourceId);
1053
+ if (!doc) {
1054
+ return yield* Effect.fail(new MemoryDocumentNotFoundError({ id: sourceId }));
1055
+ }
1056
+ yield* linkRepo.insertExplicit(sourceId, targetRef);
1057
+ }),
1058
+ listDocuments: (filter) => docRepo.listAll(filter ? { rootDir: filter.source, tags: filter.tags ? [...filter.tags] : undefined } : undefined),
1059
+ };
1060
+ }));
1061
+ //# sourceMappingURL=memory-service.js.map