@psiclawops/hypermem 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/background-indexer.d.ts +132 -0
  2. package/dist/background-indexer.d.ts.map +1 -0
  3. package/dist/background-indexer.js +1044 -0
  4. package/dist/cache.d.ts +110 -0
  5. package/dist/cache.d.ts.map +1 -0
  6. package/dist/cache.js +495 -0
  7. package/dist/compaction-fence.d.ts +89 -0
  8. package/dist/compaction-fence.d.ts.map +1 -0
  9. package/dist/compaction-fence.js +153 -0
  10. package/dist/compositor.d.ts +226 -0
  11. package/dist/compositor.d.ts.map +1 -0
  12. package/dist/compositor.js +2558 -0
  13. package/dist/content-type-classifier.d.ts +41 -0
  14. package/dist/content-type-classifier.d.ts.map +1 -0
  15. package/dist/content-type-classifier.js +181 -0
  16. package/dist/cross-agent.d.ts +62 -0
  17. package/dist/cross-agent.d.ts.map +1 -0
  18. package/dist/cross-agent.js +259 -0
  19. package/dist/db.d.ts +131 -0
  20. package/dist/db.d.ts.map +1 -0
  21. package/dist/db.js +402 -0
  22. package/dist/desired-state-store.d.ts +100 -0
  23. package/dist/desired-state-store.d.ts.map +1 -0
  24. package/dist/desired-state-store.js +222 -0
  25. package/dist/doc-chunk-store.d.ts +140 -0
  26. package/dist/doc-chunk-store.d.ts.map +1 -0
  27. package/dist/doc-chunk-store.js +391 -0
  28. package/dist/doc-chunker.d.ts +99 -0
  29. package/dist/doc-chunker.d.ts.map +1 -0
  30. package/dist/doc-chunker.js +324 -0
  31. package/dist/dreaming-promoter.d.ts +86 -0
  32. package/dist/dreaming-promoter.d.ts.map +1 -0
  33. package/dist/dreaming-promoter.js +381 -0
  34. package/dist/episode-store.d.ts +49 -0
  35. package/dist/episode-store.d.ts.map +1 -0
  36. package/dist/episode-store.js +135 -0
  37. package/dist/fact-store.d.ts +75 -0
  38. package/dist/fact-store.d.ts.map +1 -0
  39. package/dist/fact-store.js +236 -0
  40. package/dist/fleet-store.d.ts +144 -0
  41. package/dist/fleet-store.d.ts.map +1 -0
  42. package/dist/fleet-store.js +276 -0
  43. package/dist/fos-mod.d.ts +178 -0
  44. package/dist/fos-mod.d.ts.map +1 -0
  45. package/dist/fos-mod.js +416 -0
  46. package/dist/hybrid-retrieval.d.ts +64 -0
  47. package/dist/hybrid-retrieval.d.ts.map +1 -0
  48. package/dist/hybrid-retrieval.js +344 -0
  49. package/dist/image-eviction.d.ts +49 -0
  50. package/dist/image-eviction.d.ts.map +1 -0
  51. package/dist/image-eviction.js +251 -0
  52. package/dist/index.d.ts +650 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +1072 -0
  55. package/dist/keystone-scorer.d.ts +51 -0
  56. package/dist/keystone-scorer.d.ts.map +1 -0
  57. package/dist/keystone-scorer.js +52 -0
  58. package/dist/knowledge-graph.d.ts +110 -0
  59. package/dist/knowledge-graph.d.ts.map +1 -0
  60. package/dist/knowledge-graph.js +305 -0
  61. package/dist/knowledge-lint.d.ts +29 -0
  62. package/dist/knowledge-lint.d.ts.map +1 -0
  63. package/dist/knowledge-lint.js +116 -0
  64. package/dist/knowledge-store.d.ts +72 -0
  65. package/dist/knowledge-store.d.ts.map +1 -0
  66. package/dist/knowledge-store.js +247 -0
  67. package/dist/library-schema.d.ts +22 -0
  68. package/dist/library-schema.d.ts.map +1 -0
  69. package/dist/library-schema.js +1038 -0
  70. package/dist/message-store.d.ts +89 -0
  71. package/dist/message-store.d.ts.map +1 -0
  72. package/dist/message-store.js +323 -0
  73. package/dist/metrics-dashboard.d.ts +114 -0
  74. package/dist/metrics-dashboard.d.ts.map +1 -0
  75. package/dist/metrics-dashboard.js +260 -0
  76. package/dist/obsidian-exporter.d.ts +57 -0
  77. package/dist/obsidian-exporter.d.ts.map +1 -0
  78. package/dist/obsidian-exporter.js +274 -0
  79. package/dist/obsidian-watcher.d.ts +147 -0
  80. package/dist/obsidian-watcher.d.ts.map +1 -0
  81. package/dist/obsidian-watcher.js +403 -0
  82. package/dist/open-domain.d.ts +46 -0
  83. package/dist/open-domain.d.ts.map +1 -0
  84. package/dist/open-domain.js +125 -0
  85. package/dist/preference-store.d.ts +54 -0
  86. package/dist/preference-store.d.ts.map +1 -0
  87. package/dist/preference-store.js +109 -0
  88. package/dist/preservation-gate.d.ts +82 -0
  89. package/dist/preservation-gate.d.ts.map +1 -0
  90. package/dist/preservation-gate.js +150 -0
  91. package/dist/proactive-pass.d.ts +63 -0
  92. package/dist/proactive-pass.d.ts.map +1 -0
  93. package/dist/proactive-pass.js +239 -0
  94. package/dist/profiles.d.ts +44 -0
  95. package/dist/profiles.d.ts.map +1 -0
  96. package/dist/profiles.js +227 -0
  97. package/dist/provider-translator.d.ts +50 -0
  98. package/dist/provider-translator.d.ts.map +1 -0
  99. package/dist/provider-translator.js +403 -0
  100. package/dist/rate-limiter.d.ts +76 -0
  101. package/dist/rate-limiter.d.ts.map +1 -0
  102. package/dist/rate-limiter.js +179 -0
  103. package/dist/repair-tool-pairs.d.ts +38 -0
  104. package/dist/repair-tool-pairs.d.ts.map +1 -0
  105. package/dist/repair-tool-pairs.js +138 -0
  106. package/dist/retrieval-policy.d.ts +51 -0
  107. package/dist/retrieval-policy.d.ts.map +1 -0
  108. package/dist/retrieval-policy.js +77 -0
  109. package/dist/schema.d.ts +15 -0
  110. package/dist/schema.d.ts.map +1 -0
  111. package/dist/schema.js +229 -0
  112. package/dist/secret-scanner.d.ts +51 -0
  113. package/dist/secret-scanner.d.ts.map +1 -0
  114. package/dist/secret-scanner.js +248 -0
  115. package/dist/seed.d.ts +108 -0
  116. package/dist/seed.d.ts.map +1 -0
  117. package/dist/seed.js +177 -0
  118. package/dist/session-flusher.d.ts +53 -0
  119. package/dist/session-flusher.d.ts.map +1 -0
  120. package/dist/session-flusher.js +69 -0
  121. package/dist/session-topic-map.d.ts +41 -0
  122. package/dist/session-topic-map.d.ts.map +1 -0
  123. package/dist/session-topic-map.js +77 -0
  124. package/dist/spawn-context.d.ts +54 -0
  125. package/dist/spawn-context.d.ts.map +1 -0
  126. package/dist/spawn-context.js +159 -0
  127. package/dist/system-store.d.ts +73 -0
  128. package/dist/system-store.d.ts.map +1 -0
  129. package/dist/system-store.js +182 -0
  130. package/dist/temporal-store.d.ts +80 -0
  131. package/dist/temporal-store.d.ts.map +1 -0
  132. package/dist/temporal-store.js +149 -0
  133. package/dist/topic-detector.d.ts +35 -0
  134. package/dist/topic-detector.d.ts.map +1 -0
  135. package/dist/topic-detector.js +249 -0
  136. package/dist/topic-store.d.ts +45 -0
  137. package/dist/topic-store.d.ts.map +1 -0
  138. package/dist/topic-store.js +136 -0
  139. package/dist/topic-synthesizer.d.ts +51 -0
  140. package/dist/topic-synthesizer.d.ts.map +1 -0
  141. package/dist/topic-synthesizer.js +315 -0
  142. package/dist/trigger-registry.d.ts +63 -0
  143. package/dist/trigger-registry.d.ts.map +1 -0
  144. package/dist/trigger-registry.js +163 -0
  145. package/dist/types.d.ts +533 -0
  146. package/dist/types.d.ts.map +1 -0
  147. package/dist/types.js +9 -0
  148. package/dist/vector-store.d.ts +170 -0
  149. package/dist/vector-store.d.ts.map +1 -0
  150. package/dist/vector-store.js +677 -0
  151. package/dist/version.d.ts +34 -0
  152. package/dist/version.d.ts.map +1 -0
  153. package/dist/version.js +34 -0
  154. package/dist/wiki-page-emitter.d.ts +65 -0
  155. package/dist/wiki-page-emitter.d.ts.map +1 -0
  156. package/dist/wiki-page-emitter.js +258 -0
  157. package/dist/work-store.d.ts +112 -0
  158. package/dist/work-store.d.ts.map +1 -0
  159. package/dist/work-store.js +273 -0
  160. package/package.json +1 -1
@@ -0,0 +1,147 @@
1
+ /**
2
+ * hypermem Obsidian Compatibility Layer
3
+ *
4
+ * Watch a user-configured Obsidian vault directory and import markdown
5
+ * notes into the hypermem fact/doc-chunk pipeline.
6
+ *
7
+ * Obsidian-specific parsing:
8
+ * - YAML frontmatter (--- blocks): extracted as metadata, tags, aliases
9
+ * - [[Wikilinks]]: resolved to cross-references, stored as fact relationships
10
+ * - [[Wikilink|alias]]: aliased links normalized
11
+ * - ![[Embedded files]]: stripped (non-text embeds skipped)
12
+ * - #tags: extracted and stored as fact tags
13
+ * - Backlinks: tracked via wikilink resolution
14
+ *
15
+ * Design:
16
+ * - Uses fs.watch for low-dependency file watching (no chokidar)
17
+ * - Idempotent: tracks file hashes, skips unchanged files
18
+ * - Respects .obsidian/app.json excludedFolders if present
19
+ * - Sanitizes secrets before ingest (reuses secret-scanner)
20
+ * - Does NOT ingest .obsidian/ config files, templates, or attachments
21
+ *
22
+ * Config (via HyperMemConfig.obsidian):
23
+ * vaultPath: Absolute path to Obsidian vault directory
24
+ * enabled: Master switch (default: false)
25
+ * watchInterval: Polling interval ms for fs.watch fallback (default: 30000)
26
+ * collection: Doc-chunk collection name (default: 'obsidian/vault')
27
+ * excludeFolders: Additional folders to skip (merged with .obsidian excludedFolders)
28
+ * agentId: Agent to scope imported facts to (default: plugin agentId)
29
+ * importTags: Import #tags as fact tags (default: true)
30
+ * importFrontmatter: Import frontmatter fields as facts (default: true)
31
+ * staleDays: Re-import files not seen in N days (default: 7)
32
+ */
33
+ import { type DocChunk } from './doc-chunker.js';
34
+ export interface ObsidianConfig {
35
+ /** Absolute path to the Obsidian vault directory */
36
+ vaultPath: string;
37
+ /** Master switch — vault is not watched unless true */
38
+ enabled: boolean;
39
+ /** Polling interval ms for change detection (default: 30000) */
40
+ watchInterval?: number;
41
+ /** Collection name for doc-chunk store (default: 'obsidian/vault') */
42
+ collection?: string;
43
+ /** Additional folders to exclude from import */
44
+ excludeFolders?: string[];
45
+ /** Agent ID to scope imported facts to */
46
+ agentId?: string;
47
+ /** Import #tags as fact tags (default: true) */
48
+ importTags?: boolean;
49
+ /** Import frontmatter key/value pairs as facts (default: true) */
50
+ importFrontmatter?: boolean;
51
+ /** Re-import files not seen in N days (default: 7) */
52
+ staleDays?: number;
53
+ }
54
+ export interface ObsidianNote {
55
+ /** Relative path from vault root */
56
+ relativePath: string;
57
+ /** Note title (filename without extension, or frontmatter title) */
58
+ title: string;
59
+ /** Raw markdown content (post-frontmatter strip) */
60
+ content: string;
61
+ /** Parsed frontmatter fields */
62
+ frontmatter: Record<string, unknown>;
63
+ /** Extracted #tags (from content and frontmatter) */
64
+ tags: string[];
65
+ /** Wikilinks found in this note: [[target]] → target title */
66
+ wikilinks: ObsidianWikiLink[];
67
+ /** SHA-256 of original file content */
68
+ contentHash: string;
69
+ /** File mtime */
70
+ modifiedAt: Date;
71
+ }
72
+ export interface ObsidianWikiLink {
73
+ /** The target page title (normalized) */
74
+ target: string;
75
+ /** Alias if present: [[target|alias]] */
76
+ alias?: string;
77
+ /** Whether this is an embed: ![[target]] */
78
+ isEmbed: boolean;
79
+ }
80
+ export interface ObsidianImportResult {
81
+ imported: number;
82
+ skipped: number;
83
+ failed: number;
84
+ chunks: DocChunk[];
85
+ notes: ObsidianNote[];
86
+ wikilinks: Map<string, ObsidianWikiLink[]>;
87
+ }
88
+ /**
89
+ * Extract YAML frontmatter from a markdown file.
90
+ * Returns { frontmatter, body } where frontmatter is a key/value map
91
+ * and body is the content after the closing ---.
92
+ */
93
+ export declare function parseFrontmatter(content: string): {
94
+ frontmatter: Record<string, unknown>;
95
+ body: string;
96
+ };
97
+ /**
98
+ * Extract all [[wikilinks]] from markdown content.
99
+ * Handles:
100
+ * [[Page Name]] → { target: 'Page Name' }
101
+ * [[Page Name|Alias]] → { target: 'Page Name', alias: 'Alias' }
102
+ * ![[Embedded File.png]] → { target: 'Embedded File.png', isEmbed: true }
103
+ */
104
+ export declare function extractWikilinks(content: string): ObsidianWikiLink[];
105
+ /**
106
+ * Extract #tags from markdown content (not inside code blocks).
107
+ * Also merges frontmatter tags array if present.
108
+ */
109
+ export declare function extractTags(content: string, frontmatter: Record<string, unknown>): string[];
110
+ /**
111
+ * Clean Obsidian markdown for chunking:
112
+ * - Convert [[wikilinks]] to plain text (preserve the label)
113
+ * - Strip ![[embeds]] entirely
114
+ * - Strip Obsidian-specific syntax that confuses the chunker
115
+ */
116
+ export declare function cleanObsidianMarkdown(content: string): string;
117
+ /**
118
+ * Parse a single Obsidian markdown file into an ObsidianNote.
119
+ * Returns null if the file cannot be read or fails secret scanning.
120
+ */
121
+ export declare function parseObsidianNote(filePath: string, vaultRoot: string): ObsidianNote | null;
122
+ /**
123
+ * Import all notes from an Obsidian vault into hypermem doc chunks.
124
+ *
125
+ * This is the main entry point for one-shot import. The watcher calls
126
+ * this incrementally on file change events.
127
+ *
128
+ * @param config ObsidianConfig from HyperMemConfig
129
+ * @param seenHashes Map of relativePath → contentHash for skip-unchanged logic
130
+ * @returns Import result with chunks ready for doc-chunk-store insertion
131
+ */
132
+ export declare function importVault(config: ObsidianConfig, seenHashes?: Map<string, string>): ObsidianImportResult;
133
+ export type VaultChangeCallback = (result: ObsidianImportResult) => void | Promise<void>;
134
+ /**
135
+ * Watch an Obsidian vault for changes and trigger imports incrementally.
136
+ *
137
+ * Uses Node's built-in fs.watch (no chokidar dependency).
138
+ * Falls back to polling if watch events are unreliable on the platform.
139
+ *
140
+ * @param config ObsidianConfig
141
+ * @param onChange Callback receiving incremental import results on each change
142
+ * @returns stop() Call to unwatch the vault
143
+ */
144
+ export declare function watchVault(config: ObsidianConfig, onChange: VaultChangeCallback): {
145
+ stop: () => void;
146
+ };
147
+ //# sourceMappingURL=obsidian-watcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"obsidian-watcher.d.ts","sourceRoot":"","sources":["../src/obsidian-watcher.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,403 @@
1
+ /**
2
+ * hypermem Obsidian Compatibility Layer
3
+ *
4
+ * Watch a user-configured Obsidian vault directory and import markdown
5
+ * notes into the hypermem fact/doc-chunk pipeline.
6
+ *
7
+ * Obsidian-specific parsing:
8
+ * - YAML frontmatter (--- blocks): extracted as metadata, tags, aliases
9
+ * - [[Wikilinks]]: resolved to cross-references, stored as fact relationships
10
+ * - [[Wikilink|alias]]: aliased links normalized
11
+ * - ![[Embedded files]]: stripped (non-text embeds skipped)
12
+ * - #tags: extracted and stored as fact tags
13
+ * - Backlinks: tracked via wikilink resolution
14
+ *
15
+ * Design:
16
+ * - Uses fs.watch for low-dependency file watching (no chokidar)
17
+ * - Idempotent: tracks file hashes, skips unchanged files
18
+ * - Respects .obsidian/app.json excludedFolders if present
19
+ * - Sanitizes secrets before ingest (reuses secret-scanner)
20
+ * - Does NOT ingest .obsidian/ config files, templates, or attachments
21
+ *
22
+ * Config (via HyperMemConfig.obsidian):
23
+ * vaultPath: Absolute path to Obsidian vault directory
24
+ * enabled: Master switch (default: false)
25
+ * watchInterval: Polling interval ms for fs.watch fallback (default: 30000)
26
+ * collection: Doc-chunk collection name (default: 'obsidian/vault')
27
+ * excludeFolders: Additional folders to skip (merged with .obsidian excludedFolders)
28
+ * agentId: Agent to scope imported facts to (default: plugin agentId)
29
+ * importTags: Import #tags as fact tags (default: true)
30
+ * importFrontmatter: Import frontmatter fields as facts (default: true)
31
+ * staleDays: Re-import files not seen in N days (default: 7)
32
+ */
33
+ import { watch, readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
34
+ import { join, relative, extname, basename } from 'node:path';
35
+ import { hashContent, chunkMarkdown } from './doc-chunker.js';
36
+ import { isSafeForSharedVisibility } from './secret-scanner.js';
37
+ // ─── Frontmatter parser ──────────────────────────────────────────
38
+ /**
39
+ * Extract YAML frontmatter from a markdown file.
40
+ * Returns { frontmatter, body } where frontmatter is a key/value map
41
+ * and body is the content after the closing ---.
42
+ */
43
+ export function parseFrontmatter(content) {
44
+ const frontmatter = {};
45
+ if (!content.startsWith('---')) {
46
+ return { frontmatter, body: content };
47
+ }
48
+ const end = content.indexOf('\n---', 3);
49
+ if (end === -1) {
50
+ return { frontmatter, body: content };
51
+ }
52
+ const yaml = content.slice(4, end).trim();
53
+ const body = content.slice(end + 4).trim();
54
+ // Simple YAML key: value parser (no nested objects — Obsidian frontmatter is flat)
55
+ for (const line of yaml.split('\n')) {
56
+ const colonIdx = line.indexOf(':');
57
+ if (colonIdx === -1)
58
+ continue;
59
+ const key = line.slice(0, colonIdx).trim();
60
+ const rawVal = line.slice(colonIdx + 1).trim();
61
+ if (!key)
62
+ continue;
63
+ // Handle list values: "- item" lines that follow a key
64
+ // Simple case: tags: [tag1, tag2] or tags: tag1, tag2
65
+ if (rawVal.startsWith('[') && rawVal.endsWith(']')) {
66
+ frontmatter[key] = rawVal
67
+ .slice(1, -1)
68
+ .split(',')
69
+ .map(s => s.trim().replace(/^["']|["']$/g, ''))
70
+ .filter(Boolean);
71
+ }
72
+ else if (rawVal === '' || rawVal === '~' || rawVal === 'null') {
73
+ frontmatter[key] = null;
74
+ }
75
+ else if (rawVal === 'true') {
76
+ frontmatter[key] = true;
77
+ }
78
+ else if (rawVal === 'false') {
79
+ frontmatter[key] = false;
80
+ }
81
+ else if (!isNaN(Number(rawVal)) && rawVal !== '') {
82
+ frontmatter[key] = Number(rawVal);
83
+ }
84
+ else {
85
+ frontmatter[key] = rawVal.replace(/^["']|["']$/g, '');
86
+ }
87
+ }
88
+ return { frontmatter, body };
89
+ }
90
+ // ─── Wikilink extractor ──────────────────────────────────────────
91
+ /**
92
+ * Extract all [[wikilinks]] from markdown content.
93
+ * Handles:
94
+ * [[Page Name]] → { target: 'Page Name' }
95
+ * [[Page Name|Alias]] → { target: 'Page Name', alias: 'Alias' }
96
+ * ![[Embedded File.png]] → { target: 'Embedded File.png', isEmbed: true }
97
+ */
98
+ export function extractWikilinks(content) {
99
+ const links = [];
100
+ const pattern = /(!?)\[\[([^\]]+)\]\]/g;
101
+ let match;
102
+ while ((match = pattern.exec(content)) !== null) {
103
+ const isEmbed = match[1] === '!';
104
+ const inner = match[2].trim();
105
+ const pipeIdx = inner.indexOf('|');
106
+ if (pipeIdx !== -1) {
107
+ links.push({
108
+ target: inner.slice(0, pipeIdx).trim(),
109
+ alias: inner.slice(pipeIdx + 1).trim(),
110
+ isEmbed,
111
+ });
112
+ }
113
+ else {
114
+ links.push({ target: inner, isEmbed });
115
+ }
116
+ }
117
+ return links;
118
+ }
119
+ // ─── Tag extractor ───────────────────────────────────────────────
120
+ /**
121
+ * Extract #tags from markdown content (not inside code blocks).
122
+ * Also merges frontmatter tags array if present.
123
+ */
124
+ export function extractTags(content, frontmatter) {
125
+ const tags = new Set();
126
+ // Content #tags (not inside code blocks or URLs)
127
+ const tagPattern = /(?:^|\s)#([a-zA-Z][a-zA-Z0-9/_-]*)/g;
128
+ let match;
129
+ while ((match = tagPattern.exec(content)) !== null) {
130
+ tags.add(match[1].toLowerCase());
131
+ }
132
+ // Frontmatter tags
133
+ const fmTags = frontmatter['tags'];
134
+ if (Array.isArray(fmTags)) {
135
+ for (const t of fmTags) {
136
+ if (typeof t === 'string')
137
+ tags.add(t.toLowerCase().replace(/^#/, ''));
138
+ }
139
+ }
140
+ else if (typeof fmTags === 'string') {
141
+ tags.add(fmTags.toLowerCase().replace(/^#/, ''));
142
+ }
143
+ return Array.from(tags);
144
+ }
145
+ // ─── Content cleaner ─────────────────────────────────────────────
146
+ /**
147
+ * Clean Obsidian markdown for chunking:
148
+ * - Convert [[wikilinks]] to plain text (preserve the label)
149
+ * - Strip ![[embeds]] entirely
150
+ * - Strip Obsidian-specific syntax that confuses the chunker
151
+ */
152
+ export function cleanObsidianMarkdown(content) {
153
+ return content
154
+ // Remove embedded files: ![[file.png]]
155
+ .replace(/!\[\[[^\]]+\]\]/g, '')
156
+ // Convert wikilinks with alias to alias text: [[Page|Alias]] → Alias
157
+ .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2')
158
+ // Convert plain wikilinks to plain text: [[Page Name]] → Page Name
159
+ .replace(/\[\[([^\]]+)\]\]/g, '$1')
160
+ // Strip Obsidian callout syntax: > [!note]
161
+ .replace(/^>\s*\[![\w]+\]/gm, '>')
162
+ .trim();
163
+ }
164
+ // ─── Vault scanner ───────────────────────────────────────────────
165
+ /**
166
+ * Default folders to always exclude from Obsidian import.
167
+ */
168
+ const DEFAULT_EXCLUDE_FOLDERS = new Set([
169
+ '.obsidian',
170
+ '.trash',
171
+ 'templates',
172
+ 'Templates',
173
+ 'attachments',
174
+ 'Attachments',
175
+ '_attachments',
176
+ '.git',
177
+ ]);
178
+ /**
179
+ * Read Obsidian's excluded folders from .obsidian/app.json if present.
180
+ */
181
+ function readObsidianExcludedFolders(vaultPath) {
182
+ const appJson = join(vaultPath, '.obsidian', 'app.json');
183
+ if (!existsSync(appJson))
184
+ return [];
185
+ try {
186
+ const config = JSON.parse(readFileSync(appJson, 'utf-8'));
187
+ return Array.isArray(config.excludedFolders) ? config.excludedFolders : [];
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ }
193
+ /**
194
+ * Recursively collect all .md files in a vault, respecting exclusions.
195
+ */
196
+ function collectVaultFiles(dir, vaultRoot, excludeFolders, results = []) {
197
+ let entries;
198
+ try {
199
+ entries = readdirSync(dir);
200
+ }
201
+ catch {
202
+ return results;
203
+ }
204
+ for (const entry of entries) {
205
+ const fullPath = join(dir, entry);
206
+ const relPath = relative(vaultRoot, fullPath);
207
+ // Check if any path segment is excluded
208
+ const segments = relPath.split('/');
209
+ if (segments.some(s => excludeFolders.has(s)))
210
+ continue;
211
+ let stat;
212
+ try {
213
+ stat = statSync(fullPath);
214
+ }
215
+ catch {
216
+ continue;
217
+ }
218
+ if (stat.isDirectory()) {
219
+ collectVaultFiles(fullPath, vaultRoot, excludeFolders, results);
220
+ }
221
+ else if (extname(entry) === '.md') {
222
+ results.push(fullPath);
223
+ }
224
+ }
225
+ return results;
226
+ }
227
+ // ─── Note parser ─────────────────────────────────────────────────
228
+ /**
229
+ * Parse a single Obsidian markdown file into an ObsidianNote.
230
+ * Returns null if the file cannot be read or fails secret scanning.
231
+ */
232
+ export function parseObsidianNote(filePath, vaultRoot) {
233
+ let raw;
234
+ try {
235
+ raw = readFileSync(filePath, 'utf-8');
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ // Secret scan before doing anything with content
241
+ if (!isSafeForSharedVisibility(raw)) {
242
+ return null;
243
+ }
244
+ const contentHash = hashContent(raw);
245
+ const { frontmatter, body } = parseFrontmatter(raw);
246
+ const wikilinks = extractWikilinks(body);
247
+ const tags = extractTags(body, frontmatter);
248
+ const cleanedContent = cleanObsidianMarkdown(body);
249
+ const relativePath = relative(vaultRoot, filePath);
250
+ // Title: frontmatter title > filename without extension
251
+ const title = typeof frontmatter['title'] === 'string'
252
+ ? frontmatter['title']
253
+ : basename(filePath, '.md');
254
+ let modifiedAt;
255
+ try {
256
+ modifiedAt = new Date(statSync(filePath).mtimeMs);
257
+ }
258
+ catch {
259
+ modifiedAt = new Date();
260
+ }
261
+ return {
262
+ relativePath,
263
+ title,
264
+ content: cleanedContent,
265
+ frontmatter,
266
+ tags,
267
+ wikilinks,
268
+ contentHash,
269
+ modifiedAt,
270
+ };
271
+ }
272
+ // ─── Importer ────────────────────────────────────────────────────
273
+ /**
274
+ * Import all notes from an Obsidian vault into hypermem doc chunks.
275
+ *
276
+ * This is the main entry point for one-shot import. The watcher calls
277
+ * this incrementally on file change events.
278
+ *
279
+ * @param config ObsidianConfig from HyperMemConfig
280
+ * @param seenHashes Map of relativePath → contentHash for skip-unchanged logic
281
+ * @returns Import result with chunks ready for doc-chunk-store insertion
282
+ */
283
+ export function importVault(config, seenHashes = new Map()) {
284
+ const { vaultPath, collection = 'obsidian/vault', excludeFolders: userExclude = [], agentId, importTags = true, importFrontmatter: _importFm = true, } = config;
285
+ if (!existsSync(vaultPath)) {
286
+ return { imported: 0, skipped: 0, failed: 0, chunks: [], notes: [], wikilinks: new Map() };
287
+ }
288
+ // Build exclusion set
289
+ const obsidianExcluded = readObsidianExcludedFolders(vaultPath);
290
+ const excludeSet = new Set([
291
+ ...DEFAULT_EXCLUDE_FOLDERS,
292
+ ...obsidianExcluded,
293
+ ...userExclude,
294
+ ]);
295
+ const files = collectVaultFiles(vaultPath, vaultPath, excludeSet);
296
+ const allChunks = [];
297
+ const allNotes = [];
298
+ const allWikilinks = new Map();
299
+ let imported = 0;
300
+ let skipped = 0;
301
+ let failed = 0;
302
+ for (const filePath of files) {
303
+ const note = parseObsidianNote(filePath, vaultPath);
304
+ if (!note) {
305
+ failed++;
306
+ continue;
307
+ }
308
+ // Skip unchanged files
309
+ const prev = seenHashes.get(note.relativePath);
310
+ if (prev === note.contentHash) {
311
+ skipped++;
312
+ continue;
313
+ }
314
+ // Track wikilinks for cross-reference resolution
315
+ if (note.wikilinks.length > 0) {
316
+ allWikilinks.set(note.relativePath, note.wikilinks);
317
+ }
318
+ // Build tag annotation to append to content
319
+ const tagLine = importTags && note.tags.length > 0
320
+ ? `\n\n<!-- tags: ${note.tags.join(', ')} -->`
321
+ : '';
322
+ const annotatedContent = note.content + tagLine;
323
+ // Chunk the cleaned content
324
+ const chunks = chunkMarkdown(annotatedContent, {
325
+ collection,
326
+ sourcePath: note.relativePath,
327
+ scope: 'per-agent',
328
+ agentId,
329
+ });
330
+ allChunks.push(...chunks);
331
+ allNotes.push(note);
332
+ seenHashes.set(note.relativePath, note.contentHash);
333
+ imported++;
334
+ }
335
+ return {
336
+ imported,
337
+ skipped,
338
+ failed,
339
+ chunks: allChunks,
340
+ notes: allNotes,
341
+ wikilinks: allWikilinks,
342
+ };
343
+ }
344
+ /**
345
+ * Watch an Obsidian vault for changes and trigger imports incrementally.
346
+ *
347
+ * Uses Node's built-in fs.watch (no chokidar dependency).
348
+ * Falls back to polling if watch events are unreliable on the platform.
349
+ *
350
+ * @param config ObsidianConfig
351
+ * @param onChange Callback receiving incremental import results on each change
352
+ * @returns stop() Call to unwatch the vault
353
+ */
354
+ export function watchVault(config, onChange) {
355
+ const seenHashes = new Map();
356
+ const interval = config.watchInterval ?? 30_000;
357
+ // Initial import
358
+ const initial = importVault(config, seenHashes);
359
+ if (initial.imported > 0) {
360
+ void onChange(initial);
361
+ }
362
+ // Debounce: coalesce rapid change events into one import pass
363
+ let debounceTimer = null;
364
+ function scheduleImport() {
365
+ if (debounceTimer)
366
+ clearTimeout(debounceTimer);
367
+ debounceTimer = setTimeout(() => {
368
+ const result = importVault(config, seenHashes);
369
+ if (result.imported > 0) {
370
+ void onChange(result);
371
+ }
372
+ }, 1500);
373
+ }
374
+ // fs.watch on the vault directory (recursive where supported)
375
+ let watcher = null;
376
+ try {
377
+ watcher = watch(config.vaultPath, { recursive: true }, (eventType, filename) => {
378
+ if (filename && extname(filename) === '.md') {
379
+ scheduleImport();
380
+ }
381
+ });
382
+ watcher.on('error', () => {
383
+ // fs.watch failed; fall through to polling
384
+ watcher = null;
385
+ });
386
+ }
387
+ catch {
388
+ watcher = null;
389
+ }
390
+ // Polling fallback (always runs alongside watch as belt-and-suspenders)
391
+ const pollTimer = setInterval(() => {
392
+ scheduleImport();
393
+ }, interval);
394
+ return {
395
+ stop() {
396
+ if (debounceTimer)
397
+ clearTimeout(debounceTimer);
398
+ clearInterval(pollTimer);
399
+ watcher?.close();
400
+ },
401
+ };
402
+ }
403
+ //# sourceMappingURL=obsidian-watcher.js.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * open-domain.ts — Open-domain query detection and FTS5 retrieval
3
+ *
4
+ * LoCoMo benchmark open-domain questions are broad, exploratory, and have no
5
+ * topical anchor. They span the full conversation history and require content
6
+ * that may have been filtered out by the quality gate (isQualityFact). The
7
+ * fix: detect open-domain queries and run a separate FTS5 search against raw
8
+ * messages_fts, bypassing the quality filter entirely.
9
+ *
10
+ * Detection heuristics (conservative — false positives add noise):
11
+ * - Short query with no named entities (no TitleCase tokens)
12
+ * - Broad interrogative patterns (what did, how did, tell me about, etc.)
13
+ * - No temporal signals (those go to the temporal retrieval path)
14
+ * - No specific identifiers (URLs, IDs, ticket numbers, version strings)
15
+ *
16
+ * Retrieval: MessageStore.searchMessages() against messages_fts — covers all
17
+ * raw message history regardless of quality gate.
18
+ */
19
+ import type { DatabaseSync } from 'node:sqlite';
20
+ /**
21
+ * Returns true if the query looks like an open-domain question:
22
+ * broad, exploratory, no specific anchors, no temporal signals.
23
+ */
24
+ export declare function isOpenDomainQuery(query: string): boolean;
25
+ /**
26
+ * Build a FTS5 MATCH query from a broad question.
27
+ * Strips stop words, question words, and punctuation.
28
+ * Returns up to 6 prefix-matched terms joined with OR.
29
+ */
30
+ export declare function buildOpenDomainFtsQuery(query: string): string | null;
31
+ export interface OpenDomainResult {
32
+ role: string;
33
+ content: string;
34
+ createdAt: string;
35
+ }
36
+ /**
37
+ * Search raw message history via FTS5 for open-domain queries.
38
+ * Returns up to `limit` matching messages, deduplicated against existing context.
39
+ *
40
+ * @param db — agent messages DB (contains messages_fts)
41
+ * @param query — the user's query
42
+ * @param existingContent — already-assembled context (for dedup)
43
+ * @param limit — max results (default 10)
44
+ */
45
+ export declare function searchOpenDomain(db: DatabaseSync, query: string, existingContent: string, limit?: number): OpenDomainResult[];
46
+ //# sourceMappingURL=open-domain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-domain.d.ts","sourceRoot":"","sources":["../src/open-domain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAUhD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAiBxD;AAID;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBpE;AAID,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,EACb,eAAe,EAAE,MAAM,EACvB,KAAK,GAAE,MAAW,GACjB,gBAAgB,EAAE,CA4CpB"}