@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.
- package/dist/background-indexer.d.ts +132 -0
- package/dist/background-indexer.d.ts.map +1 -0
- package/dist/background-indexer.js +1044 -0
- package/dist/cache.d.ts +110 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +495 -0
- package/dist/compaction-fence.d.ts +89 -0
- package/dist/compaction-fence.d.ts.map +1 -0
- package/dist/compaction-fence.js +153 -0
- package/dist/compositor.d.ts +226 -0
- package/dist/compositor.d.ts.map +1 -0
- package/dist/compositor.js +2558 -0
- package/dist/content-type-classifier.d.ts +41 -0
- package/dist/content-type-classifier.d.ts.map +1 -0
- package/dist/content-type-classifier.js +181 -0
- package/dist/cross-agent.d.ts +62 -0
- package/dist/cross-agent.d.ts.map +1 -0
- package/dist/cross-agent.js +259 -0
- package/dist/db.d.ts +131 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +402 -0
- package/dist/desired-state-store.d.ts +100 -0
- package/dist/desired-state-store.d.ts.map +1 -0
- package/dist/desired-state-store.js +222 -0
- package/dist/doc-chunk-store.d.ts +140 -0
- package/dist/doc-chunk-store.d.ts.map +1 -0
- package/dist/doc-chunk-store.js +391 -0
- package/dist/doc-chunker.d.ts +99 -0
- package/dist/doc-chunker.d.ts.map +1 -0
- package/dist/doc-chunker.js +324 -0
- package/dist/dreaming-promoter.d.ts +86 -0
- package/dist/dreaming-promoter.d.ts.map +1 -0
- package/dist/dreaming-promoter.js +381 -0
- package/dist/episode-store.d.ts +49 -0
- package/dist/episode-store.d.ts.map +1 -0
- package/dist/episode-store.js +135 -0
- package/dist/fact-store.d.ts +75 -0
- package/dist/fact-store.d.ts.map +1 -0
- package/dist/fact-store.js +236 -0
- package/dist/fleet-store.d.ts +144 -0
- package/dist/fleet-store.d.ts.map +1 -0
- package/dist/fleet-store.js +276 -0
- package/dist/fos-mod.d.ts +178 -0
- package/dist/fos-mod.d.ts.map +1 -0
- package/dist/fos-mod.js +416 -0
- package/dist/hybrid-retrieval.d.ts +64 -0
- package/dist/hybrid-retrieval.d.ts.map +1 -0
- package/dist/hybrid-retrieval.js +344 -0
- package/dist/image-eviction.d.ts +49 -0
- package/dist/image-eviction.d.ts.map +1 -0
- package/dist/image-eviction.js +251 -0
- package/dist/index.d.ts +650 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1072 -0
- package/dist/keystone-scorer.d.ts +51 -0
- package/dist/keystone-scorer.d.ts.map +1 -0
- package/dist/keystone-scorer.js +52 -0
- package/dist/knowledge-graph.d.ts +110 -0
- package/dist/knowledge-graph.d.ts.map +1 -0
- package/dist/knowledge-graph.js +305 -0
- package/dist/knowledge-lint.d.ts +29 -0
- package/dist/knowledge-lint.d.ts.map +1 -0
- package/dist/knowledge-lint.js +116 -0
- package/dist/knowledge-store.d.ts +72 -0
- package/dist/knowledge-store.d.ts.map +1 -0
- package/dist/knowledge-store.js +247 -0
- package/dist/library-schema.d.ts +22 -0
- package/dist/library-schema.d.ts.map +1 -0
- package/dist/library-schema.js +1038 -0
- package/dist/message-store.d.ts +89 -0
- package/dist/message-store.d.ts.map +1 -0
- package/dist/message-store.js +323 -0
- package/dist/metrics-dashboard.d.ts +114 -0
- package/dist/metrics-dashboard.d.ts.map +1 -0
- package/dist/metrics-dashboard.js +260 -0
- package/dist/obsidian-exporter.d.ts +57 -0
- package/dist/obsidian-exporter.d.ts.map +1 -0
- package/dist/obsidian-exporter.js +274 -0
- package/dist/obsidian-watcher.d.ts +147 -0
- package/dist/obsidian-watcher.d.ts.map +1 -0
- package/dist/obsidian-watcher.js +403 -0
- package/dist/open-domain.d.ts +46 -0
- package/dist/open-domain.d.ts.map +1 -0
- package/dist/open-domain.js +125 -0
- package/dist/preference-store.d.ts +54 -0
- package/dist/preference-store.d.ts.map +1 -0
- package/dist/preference-store.js +109 -0
- package/dist/preservation-gate.d.ts +82 -0
- package/dist/preservation-gate.d.ts.map +1 -0
- package/dist/preservation-gate.js +150 -0
- package/dist/proactive-pass.d.ts +63 -0
- package/dist/proactive-pass.d.ts.map +1 -0
- package/dist/proactive-pass.js +239 -0
- package/dist/profiles.d.ts +44 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +227 -0
- package/dist/provider-translator.d.ts +50 -0
- package/dist/provider-translator.d.ts.map +1 -0
- package/dist/provider-translator.js +403 -0
- package/dist/rate-limiter.d.ts +76 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +179 -0
- package/dist/repair-tool-pairs.d.ts +38 -0
- package/dist/repair-tool-pairs.d.ts.map +1 -0
- package/dist/repair-tool-pairs.js +138 -0
- package/dist/retrieval-policy.d.ts +51 -0
- package/dist/retrieval-policy.d.ts.map +1 -0
- package/dist/retrieval-policy.js +77 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +229 -0
- package/dist/secret-scanner.d.ts +51 -0
- package/dist/secret-scanner.d.ts.map +1 -0
- package/dist/secret-scanner.js +248 -0
- package/dist/seed.d.ts +108 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +177 -0
- package/dist/session-flusher.d.ts +53 -0
- package/dist/session-flusher.d.ts.map +1 -0
- package/dist/session-flusher.js +69 -0
- package/dist/session-topic-map.d.ts +41 -0
- package/dist/session-topic-map.d.ts.map +1 -0
- package/dist/session-topic-map.js +77 -0
- package/dist/spawn-context.d.ts +54 -0
- package/dist/spawn-context.d.ts.map +1 -0
- package/dist/spawn-context.js +159 -0
- package/dist/system-store.d.ts +73 -0
- package/dist/system-store.d.ts.map +1 -0
- package/dist/system-store.js +182 -0
- package/dist/temporal-store.d.ts +80 -0
- package/dist/temporal-store.d.ts.map +1 -0
- package/dist/temporal-store.js +149 -0
- package/dist/topic-detector.d.ts +35 -0
- package/dist/topic-detector.d.ts.map +1 -0
- package/dist/topic-detector.js +249 -0
- package/dist/topic-store.d.ts +45 -0
- package/dist/topic-store.d.ts.map +1 -0
- package/dist/topic-store.js +136 -0
- package/dist/topic-synthesizer.d.ts +51 -0
- package/dist/topic-synthesizer.d.ts.map +1 -0
- package/dist/topic-synthesizer.js +315 -0
- package/dist/trigger-registry.d.ts +63 -0
- package/dist/trigger-registry.d.ts.map +1 -0
- package/dist/trigger-registry.js +163 -0
- package/dist/types.d.ts +533 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/vector-store.d.ts +170 -0
- package/dist/vector-store.d.ts.map +1 -0
- package/dist/vector-store.js +677 -0
- package/dist/version.d.ts +34 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +34 -0
- package/dist/wiki-page-emitter.d.ts +65 -0
- package/dist/wiki-page-emitter.d.ts.map +1 -0
- package/dist/wiki-page-emitter.js +258 -0
- package/dist/work-store.d.ts +112 -0
- package/dist/work-store.d.ts.map +1 -0
- package/dist/work-store.js +273 -0
- 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"}
|