synabun 2026.3.303 → 2026.3.305

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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * Shared SQLite database layer for the Neural Interface.
3
+ * Uses node:sqlite (built-in since Node 22.5.0).
4
+ * Shares the same memory.db file as the MCP server (WAL mode for concurrent access).
5
+ */
6
+
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { resolve, dirname } from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { existsSync, mkdirSync } from 'fs';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ const MCP_DATA_DIR = resolve(__dirname, '..', '..', 'mcp-server', 'data');
16
+ const DEFAULT_DB_PATH = resolve(MCP_DATA_DIR, 'memory.db');
17
+
18
+ let db = null;
19
+
20
+ // --- Embedding ---
21
+
22
+ let embeddingPipeline = null;
23
+ let embeddingInitPromise = null;
24
+ export const EMBEDDING_MODEL = 'Xenova/all-MiniLM-L6-v2';
25
+ export const EMBEDDING_DIMS = 384;
26
+
27
+ async function getEmbeddingPipeline() {
28
+ if (embeddingPipeline) return embeddingPipeline;
29
+ if (embeddingInitPromise) return embeddingInitPromise;
30
+
31
+ embeddingInitPromise = (async () => {
32
+ const { pipeline } = await import('@huggingface/transformers');
33
+ embeddingPipeline = await pipeline('feature-extraction', EMBEDDING_MODEL, {
34
+ dtype: 'fp32',
35
+ });
36
+ return embeddingPipeline;
37
+ })();
38
+
39
+ try {
40
+ return await embeddingInitPromise;
41
+ } finally {
42
+ embeddingInitPromise = null;
43
+ }
44
+ }
45
+
46
+ export async function getEmbedding(text) {
47
+ const ext = await getEmbeddingPipeline();
48
+ const output = await ext(text, { pooling: 'mean', normalize: true });
49
+ return Array.from(output.data);
50
+ }
51
+
52
+ export async function getEmbeddingBatch(texts) {
53
+ if (texts.length === 0) return [];
54
+ if (texts.length === 1) return [await getEmbedding(texts[0])];
55
+
56
+ const ext = await getEmbeddingPipeline();
57
+ const results = [];
58
+ for (const text of texts) {
59
+ const output = await ext(text, { pooling: 'mean', normalize: true });
60
+ results.push(Array.from(output.data));
61
+ }
62
+ return results;
63
+ }
64
+
65
+ export async function warmupEmbeddings() {
66
+ await getEmbeddingPipeline();
67
+ }
68
+
69
+ export function getEmbeddingDims() {
70
+ return EMBEDDING_DIMS;
71
+ }
72
+
73
+ // --- Database ---
74
+
75
+ export function getDbPath() {
76
+ const envPath = process.env.SQLITE_DB_PATH;
77
+ if (!envPath) return DEFAULT_DB_PATH;
78
+ // If the env path's parent directory doesn't exist (e.g. Windows path on Mac),
79
+ // fall back to the local default so cross-OS restores work instantly
80
+ if (!existsSync(dirname(envPath))) return DEFAULT_DB_PATH;
81
+ return envPath;
82
+ }
83
+
84
+ export function getDb() {
85
+ if (!db) {
86
+ const dbPath = getDbPath();
87
+ const dir = dirname(dbPath);
88
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
89
+
90
+ db = new DatabaseSync(dbPath);
91
+ db.exec('PRAGMA journal_mode = WAL');
92
+ db.exec('PRAGMA foreign_keys = ON');
93
+ db.exec('PRAGMA busy_timeout = 5000');
94
+
95
+ // Ensure schema exists (same tables as MCP server)
96
+ db.exec(SCHEMA_SQL);
97
+ try { db.exec(FTS_SQL); } catch { /* FTS5 may already exist */ }
98
+ }
99
+ return db;
100
+ }
101
+
102
+ const SCHEMA_SQL = `
103
+ CREATE TABLE IF NOT EXISTS memories (
104
+ id TEXT PRIMARY KEY,
105
+ vector BLOB NOT NULL,
106
+ content TEXT NOT NULL,
107
+ category TEXT NOT NULL,
108
+ subcategory TEXT,
109
+ project TEXT NOT NULL,
110
+ tags TEXT NOT NULL DEFAULT '[]',
111
+ importance INTEGER NOT NULL DEFAULT 5,
112
+ source TEXT NOT NULL DEFAULT 'self-discovered',
113
+ created_at TEXT NOT NULL,
114
+ updated_at TEXT NOT NULL,
115
+ accessed_at TEXT NOT NULL,
116
+ access_count INTEGER NOT NULL DEFAULT 0,
117
+ related_files TEXT,
118
+ related_memory_ids TEXT,
119
+ file_checksums TEXT,
120
+ trashed_at TEXT,
121
+ source_session_chunks TEXT
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS session_chunks (
125
+ id TEXT PRIMARY KEY,
126
+ vector BLOB NOT NULL,
127
+ content TEXT NOT NULL,
128
+ summary TEXT,
129
+ session_id TEXT,
130
+ project TEXT,
131
+ git_branch TEXT,
132
+ cwd TEXT,
133
+ chunk_index INTEGER DEFAULT 0,
134
+ start_timestamp TEXT,
135
+ end_timestamp TEXT,
136
+ tools_used TEXT DEFAULT '[]',
137
+ files_modified TEXT DEFAULT '[]',
138
+ files_read TEXT DEFAULT '[]',
139
+ user_messages TEXT DEFAULT '[]',
140
+ turn_count INTEGER DEFAULT 0,
141
+ related_memory_ids TEXT DEFAULT '[]',
142
+ dedup_memory_id TEXT,
143
+ indexed_at TEXT
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS categories (
147
+ name TEXT PRIMARY KEY,
148
+ description TEXT NOT NULL,
149
+ created_at TEXT NOT NULL,
150
+ parent TEXT,
151
+ color TEXT,
152
+ is_parent INTEGER DEFAULT 0
153
+ );
154
+
155
+ CREATE INDEX IF NOT EXISTS idx_mem_category ON memories(category);
156
+ CREATE INDEX IF NOT EXISTS idx_mem_project ON memories(project);
157
+ CREATE INDEX IF NOT EXISTS idx_mem_importance ON memories(importance);
158
+ CREATE INDEX IF NOT EXISTS idx_mem_trashed ON memories(trashed_at);
159
+ CREATE INDEX IF NOT EXISTS idx_mem_created ON memories(created_at);
160
+ CREATE INDEX IF NOT EXISTS idx_mem_source ON memories(source);
161
+ CREATE INDEX IF NOT EXISTS idx_sc_session ON session_chunks(session_id);
162
+ CREATE INDEX IF NOT EXISTS idx_sc_project ON session_chunks(project);
163
+ CREATE INDEX IF NOT EXISTS idx_sc_branch ON session_chunks(git_branch);
164
+
165
+ CREATE TABLE IF NOT EXISTS kv_config (
166
+ key TEXT PRIMARY KEY,
167
+ value TEXT NOT NULL
168
+ );
169
+ `;
170
+
171
+ const FTS_SQL = `
172
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
173
+ content, category, project, tags,
174
+ content=memories, content_rowid=rowid,
175
+ tokenize='porter unicode61'
176
+ );
177
+ `;
178
+
179
+ export function closeDb() {
180
+ if (db) {
181
+ db.close();
182
+ db = null;
183
+ }
184
+ }
185
+
186
+ // --- KV Config ---
187
+
188
+ const KV_DDL = 'CREATE TABLE IF NOT EXISTS kv_config (key TEXT PRIMARY KEY, value TEXT NOT NULL)';
189
+
190
+ export function getKvConfig(key) {
191
+ const d = getDb();
192
+ d.exec(KV_DDL);
193
+ const row = d.prepare('SELECT value FROM kv_config WHERE key = ?').get(key);
194
+ return row ? row.value : null;
195
+ }
196
+
197
+ export function setKvConfig(key, value) {
198
+ const d = getDb();
199
+ d.exec(KV_DDL);
200
+ d.prepare('INSERT OR REPLACE INTO kv_config (key, value) VALUES (?, ?)').run(key, String(value));
201
+ }
202
+
203
+ // --- Vector helpers ---
204
+
205
+ export function encodeVector(vector) {
206
+ return new Uint8Array(new Float32Array(vector).buffer);
207
+ }
208
+
209
+ export function decodeVector(blob) {
210
+ return Array.from(new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4));
211
+ }
212
+
213
+ export function cosineSimilarity(a, b) {
214
+ let dot = 0;
215
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
216
+ return dot;
217
+ }
218
+
219
+ // --- Memory queries ---
220
+
221
+ /**
222
+ * Search memories by vector similarity.
223
+ * Optionally filter by category/project/tags. Excludes trashed and system_metadata.
224
+ */
225
+ export function searchMemories(vector, limit = 10, { category, project, tags, minImportance, scoreThreshold = 0.3, includeTrash = false } = {}) {
226
+ const d = getDb();
227
+ const clauses = [];
228
+ const params = [];
229
+
230
+ if (!includeTrash) {
231
+ clauses.push('trashed_at IS NULL');
232
+ }
233
+
234
+ if (category) {
235
+ clauses.push('category = ?');
236
+ params.push(category);
237
+ }
238
+ if (project) {
239
+ clauses.push('project = ?');
240
+ params.push(project);
241
+ }
242
+ if (tags && tags.length > 0) {
243
+ for (const tag of tags) {
244
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(tags) WHERE json_each.value = ?)`);
245
+ params.push(tag);
246
+ }
247
+ }
248
+ if (minImportance) {
249
+ clauses.push('importance >= ?');
250
+ params.push(minImportance);
251
+ }
252
+
253
+ const where = clauses.length > 0 ? 'WHERE ' + clauses.join(' AND ') : '';
254
+
255
+ const rows = d.prepare(`
256
+ SELECT id, vector, content, category, subcategory, project, tags, importance, source,
257
+ created_at, updated_at, accessed_at, access_count, related_files,
258
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
259
+ FROM memories ${where}
260
+ `).all(...params);
261
+
262
+ const scored = rows.map(row => {
263
+ const rowVec = decodeVector(row.vector);
264
+ const score = cosineSimilarity(vector, rowVec);
265
+ return { ...rowToPayload(row), id: row.id, score, vector: rowVec };
266
+ }).filter(r => r.score >= scoreThreshold);
267
+
268
+ scored.sort((a, b) => b.score - a.score);
269
+ return scored.slice(0, limit);
270
+ }
271
+
272
+ /**
273
+ * Get all memories (for graph view, links computation, etc.)
274
+ */
275
+ export function getAllMemories({ includeVectors = false, includeTrash = false } = {}) {
276
+ const d = getDb();
277
+ const vectorCol = includeVectors ? ', vector' : '';
278
+ const where = includeTrash ? '' : 'WHERE trashed_at IS NULL';
279
+
280
+ const rows = d.prepare(`
281
+ SELECT id, content, category, subcategory, project, tags, importance, source,
282
+ created_at, updated_at, accessed_at, access_count, related_files,
283
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
284
+ ${vectorCol}
285
+ FROM memories ${where}
286
+ ORDER BY created_at DESC
287
+ `).all();
288
+
289
+ return rows.map(row => {
290
+ const payload = rowToPayload(row);
291
+ const result = { id: row.id, ...payload };
292
+ if (includeVectors && row.vector) {
293
+ result.vector = decodeVector(row.vector);
294
+ }
295
+ return result;
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Get all memories with vectors for link computation.
301
+ */
302
+ export function getAllMemoriesWithVectors() {
303
+ const d = getDb();
304
+ const rows = d.prepare(`
305
+ SELECT id, vector, content, category, subcategory, project, tags, importance, source,
306
+ created_at, updated_at, accessed_at, access_count, related_files,
307
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
308
+ FROM memories WHERE trashed_at IS NULL
309
+ `).all();
310
+
311
+ return rows.map(row => ({
312
+ id: row.id,
313
+ vector: decodeVector(row.vector),
314
+ ...rowToPayload(row),
315
+ }));
316
+ }
317
+
318
+ export function getMemoryById(id) {
319
+ const d = getDb();
320
+ const row = d.prepare(`
321
+ SELECT id, content, category, subcategory, project, tags, importance, source,
322
+ created_at, updated_at, accessed_at, access_count, related_files,
323
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
324
+ FROM memories WHERE id = ?
325
+ `).get(id);
326
+
327
+ if (!row) return null;
328
+ return { id: row.id, ...rowToPayload(row) };
329
+ }
330
+
331
+ export function getMemoryWithVector(id) {
332
+ const d = getDb();
333
+ const row = d.prepare(`
334
+ SELECT id, vector, content, category, subcategory, project, tags, importance, source,
335
+ created_at, updated_at, accessed_at, access_count, related_files,
336
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
337
+ FROM memories WHERE id = ?
338
+ `).get(id);
339
+
340
+ if (!row) return null;
341
+ return { id: row.id, vector: decodeVector(row.vector), ...rowToPayload(row) };
342
+ }
343
+
344
+ export function updateMemoryPayload(id, updates) {
345
+ const d = getDb();
346
+ const sets = [];
347
+ const params = [];
348
+
349
+ for (const [key, value] of Object.entries(updates)) {
350
+ if (key === 'id' || key === 'vector') continue;
351
+ if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
352
+ sets.push(`${key} = ?`);
353
+ params.push(JSON.stringify(value));
354
+ } else {
355
+ sets.push(`${key} = ?`);
356
+ params.push(value ?? null);
357
+ }
358
+ }
359
+
360
+ if (sets.length === 0) return;
361
+ params.push(id);
362
+ d.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`).run(...params);
363
+ }
364
+
365
+ export function softDeleteMemory(id) {
366
+ const d = getDb();
367
+ d.prepare('UPDATE memories SET trashed_at = ? WHERE id = ?').run(new Date().toISOString(), id);
368
+ }
369
+
370
+ export function hardDeleteMemory(id) {
371
+ const d = getDb();
372
+ d.prepare('DELETE FROM memories WHERE id = ?').run(id);
373
+ }
374
+
375
+ export function restoreMemory(id) {
376
+ const d = getDb();
377
+ d.prepare('UPDATE memories SET trashed_at = NULL WHERE id = ?').run(id);
378
+ }
379
+
380
+ export function getTrashedMemories() {
381
+ const d = getDb();
382
+ const rows = d.prepare(`
383
+ SELECT id, content, category, subcategory, project, tags, importance, source,
384
+ created_at, updated_at, accessed_at, access_count, related_files,
385
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
386
+ FROM memories WHERE trashed_at IS NOT NULL
387
+ ORDER BY trashed_at DESC
388
+ `).all();
389
+
390
+ return rows.map(row => ({ id: row.id, ...rowToPayload(row) }));
391
+ }
392
+
393
+ export function purgeTrash() {
394
+ const d = getDb();
395
+ const rows = d.prepare('SELECT id FROM memories WHERE trashed_at IS NOT NULL').all();
396
+ d.prepare('DELETE FROM memories WHERE trashed_at IS NOT NULL').run();
397
+ return rows.map(r => r.id);
398
+ }
399
+
400
+ export function countMemories(filter = {}) {
401
+ const d = getDb();
402
+ const clauses = ['trashed_at IS NULL'];
403
+ const params = [];
404
+
405
+ if (filter.category) {
406
+ clauses.push('category = ?');
407
+ params.push(filter.category);
408
+ }
409
+ if (filter.project) {
410
+ clauses.push('project = ?');
411
+ params.push(filter.project);
412
+ }
413
+
414
+ const result = d.prepare(`SELECT COUNT(*) as cnt FROM memories WHERE ${clauses.join(' AND ')}`).get(...params);
415
+ return result.cnt;
416
+ }
417
+
418
+ export function getMemoryStats() {
419
+ const d = getDb();
420
+
421
+ const total = d.prepare('SELECT COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL').get().cnt;
422
+ const trashedCount = d.prepare('SELECT COUNT(*) as cnt FROM memories WHERE trashed_at IS NOT NULL').get().cnt;
423
+
424
+ const catRows = d.prepare('SELECT category, COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL GROUP BY category').all();
425
+ const by_category = {};
426
+ for (const row of catRows) by_category[row.category] = row.cnt;
427
+
428
+ const projRows = d.prepare('SELECT project, COUNT(*) as cnt FROM memories WHERE trashed_at IS NULL GROUP BY project').all();
429
+ const by_project = {};
430
+ for (const row of projRows) by_project[row.project || 'global'] = row.cnt;
431
+
432
+ const dateRow = d.prepare('SELECT MIN(created_at) as oldest, MAX(created_at) as newest FROM memories WHERE trashed_at IS NULL').get();
433
+
434
+ return {
435
+ total,
436
+ trashedCount,
437
+ by_category,
438
+ by_project,
439
+ oldest: dateRow.oldest,
440
+ newest: dateRow.newest,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Get memories by category for export.
446
+ */
447
+ export function getMemoriesByCategory(category) {
448
+ const d = getDb();
449
+ const rows = d.prepare(`
450
+ SELECT id, content, category, subcategory, project, tags, importance, source,
451
+ created_at, updated_at, accessed_at, access_count, related_files,
452
+ related_memory_ids, file_checksums, trashed_at, source_session_chunks
453
+ FROM memories WHERE category = ? AND trashed_at IS NULL
454
+ ORDER BY created_at DESC
455
+ `).all(category);
456
+
457
+ return rows.map(row => ({ id: row.id, ...rowToPayload(row) }));
458
+ }
459
+
460
+ /**
461
+ * Batch update category for memories.
462
+ */
463
+ export function updateMemoriesCategory(ids, newCategory) {
464
+ const d = getDb();
465
+ d.exec('BEGIN');
466
+ try {
467
+ const stmt = d.prepare('UPDATE memories SET category = ? WHERE id = ?');
468
+ for (const id of ids) {
469
+ stmt.run(newCategory, id);
470
+ }
471
+ d.exec('COMMIT');
472
+ } catch (err) {
473
+ d.exec('ROLLBACK');
474
+ throw err;
475
+ }
476
+ }
477
+
478
+ // --- Categories ---
479
+
480
+ export function getCategories() {
481
+ const d = getDb();
482
+ return d.prepare('SELECT name, description, created_at, parent, color, is_parent FROM categories ORDER BY name').all();
483
+ }
484
+
485
+ export function saveCategories(categories) {
486
+ const d = getDb();
487
+ d.exec('BEGIN');
488
+ try {
489
+ const existing = new Set(d.prepare('SELECT name FROM categories').all().map(r => r.name));
490
+ const incoming = new Set(categories.map(c => c.name));
491
+
492
+ for (const name of existing) {
493
+ if (!incoming.has(name)) {
494
+ d.prepare('DELETE FROM categories WHERE name = ?').run(name);
495
+ }
496
+ }
497
+
498
+ const stmt = d.prepare('INSERT OR REPLACE INTO categories (name, description, created_at, parent, color, is_parent) VALUES (?, ?, ?, ?, ?, ?)');
499
+ for (const cat of categories) {
500
+ stmt.run(cat.name, cat.description, cat.created_at, cat.parent || null, cat.color || null, cat.is_parent ? 1 : 0);
501
+ }
502
+ d.exec('COMMIT');
503
+ } catch (err) {
504
+ d.exec('ROLLBACK');
505
+ throw err;
506
+ }
507
+ }
508
+
509
+ // --- Session Chunks ---
510
+
511
+ export function countSessionChunks() {
512
+ const d = getDb();
513
+ return d.prepare('SELECT COUNT(*) as cnt FROM session_chunks').get().cnt;
514
+ }
515
+
516
+ export function searchSessionChunks(vector, limit = 10, { project, scoreThreshold = 0.3 } = {}) {
517
+ const d = getDb();
518
+ const clauses = [];
519
+ const params = [];
520
+
521
+ if (project) {
522
+ clauses.push('project = ?');
523
+ params.push(project);
524
+ }
525
+
526
+ const where = clauses.length > 0 ? 'WHERE ' + clauses.join(' AND ') : '';
527
+
528
+ const rows = d.prepare(`
529
+ SELECT id, vector, content, summary, session_id, project, git_branch, cwd,
530
+ chunk_index, start_timestamp, end_timestamp, tools_used, files_modified,
531
+ files_read, user_messages, turn_count, related_memory_ids, dedup_memory_id, indexed_at
532
+ FROM session_chunks ${where}
533
+ `).all(...params);
534
+
535
+ const scored = rows.map(row => {
536
+ const rowVec = decodeVector(row.vector);
537
+ const score = cosineSimilarity(vector, rowVec);
538
+ return { id: row.id, score, payload: rowToSessionPayload(row) };
539
+ }).filter(r => r.score >= scoreThreshold);
540
+
541
+ scored.sort((a, b) => b.score - a.score);
542
+ return scored.slice(0, limit);
543
+ }
544
+
545
+ // --- Row conversion helpers ---
546
+
547
+ function parseJson(val, fallback) {
548
+ if (val == null || val === '') return fallback;
549
+ if (typeof val === 'string') {
550
+ try { return JSON.parse(val); } catch { return fallback; }
551
+ }
552
+ return val;
553
+ }
554
+
555
+ function rowToPayload(row) {
556
+ return {
557
+ content: row.content,
558
+ category: row.category,
559
+ subcategory: row.subcategory || undefined,
560
+ project: row.project,
561
+ tags: parseJson(row.tags, []),
562
+ importance: row.importance ?? 5,
563
+ source: row.source || 'self-discovered',
564
+ created_at: row.created_at,
565
+ updated_at: row.updated_at,
566
+ accessed_at: row.accessed_at,
567
+ access_count: row.access_count ?? 0,
568
+ related_files: parseJson(row.related_files, undefined),
569
+ related_memory_ids: parseJson(row.related_memory_ids, undefined),
570
+ file_checksums: parseJson(row.file_checksums, undefined),
571
+ trashed_at: row.trashed_at || null,
572
+ source_session_chunks: parseJson(row.source_session_chunks, undefined),
573
+ };
574
+ }
575
+
576
+ function rowToSessionPayload(row) {
577
+ return {
578
+ content: row.content,
579
+ summary: row.summary || '',
580
+ session_id: row.session_id || '',
581
+ project: row.project || '',
582
+ git_branch: row.git_branch || null,
583
+ cwd: row.cwd || null,
584
+ chunk_index: row.chunk_index ?? 0,
585
+ start_timestamp: row.start_timestamp || '',
586
+ end_timestamp: row.end_timestamp || '',
587
+ tools_used: parseJson(row.tools_used, []),
588
+ files_modified: parseJson(row.files_modified, []),
589
+ files_read: parseJson(row.files_read, []),
590
+ user_messages: parseJson(row.user_messages, []),
591
+ turn_count: row.turn_count ?? 0,
592
+ related_memory_ids: parseJson(row.related_memory_ids, []),
593
+ dedup_memory_id: row.dedup_memory_id || null,
594
+ indexed_at: row.indexed_at || '',
595
+ };
596
+ }
@@ -0,0 +1,77 @@
1
+ param([string]$Title = 'Select a folder')
2
+
3
+ Add-Type -AssemblyName System.Windows.Forms
4
+
5
+ Add-Type -TypeDefinition @'
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+ using System.Windows.Forms;
9
+
10
+ [ComImport, Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
11
+ class FileOpenDialogRCW {}
12
+
13
+ [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"),
14
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
15
+ interface IShellItem {
16
+ void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
17
+ void GetParent(out IShellItem ppsi);
18
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
19
+ void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
20
+ void Compare(IShellItem psi, uint hint, out int piOrder);
21
+ }
22
+
23
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"),
24
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
25
+ interface IFileOpenDialog {
26
+ [PreserveSig] uint Show(IntPtr hwndOwner);
27
+ void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
28
+ void SetFileTypeIndex(uint iFileType);
29
+ void GetFileTypeIndex(out uint piFileType);
30
+ void Advise(IntPtr pfde, out uint pdwCookie);
31
+ void Unadvise(uint dwCookie);
32
+ void SetOptions(uint fos);
33
+ void GetOptions(out uint pfos);
34
+ void SetDefaultFolder(IShellItem psi);
35
+ void SetFolder(IShellItem psi);
36
+ void GetFolder(out IShellItem ppsi);
37
+ void GetCurrentSelection(out IShellItem ppsi);
38
+ void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
39
+ void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
40
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
41
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
42
+ void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
43
+ void GetResult(out IShellItem ppsi);
44
+ void AddPlace(IShellItem psi, int fdap);
45
+ void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
46
+ void Close(int hr);
47
+ void SetClientGuid(ref Guid guid);
48
+ void ClearClientData();
49
+ void SetFilter(IntPtr pFilter);
50
+ void GetResults(out IntPtr ppenum);
51
+ void GetSelectedItems(out IntPtr ppsai);
52
+ }
53
+
54
+ public static class FolderPicker {
55
+ [DllImport("user32.dll")]
56
+ static extern IntPtr GetForegroundWindow();
57
+
58
+ public static string Pick(string title) {
59
+ // Grab the currently focused window (the browser) so the dialog opens on top of it
60
+ IntPtr foreground = GetForegroundWindow();
61
+
62
+ var dlg = (IFileOpenDialog)new FileOpenDialogRCW();
63
+ dlg.SetOptions(0x20); // FOS_PICKFOLDERS
64
+ if (!string.IsNullOrEmpty(title)) dlg.SetTitle(title);
65
+ uint hr = dlg.Show(foreground);
66
+ if (hr != 0) return string.Empty;
67
+ IShellItem item;
68
+ dlg.GetResult(out item);
69
+ string path;
70
+ item.GetDisplayName(0x80058000, out path); // SIGDN_DESKTOPABSOLUTEPARSING
71
+ return path ?? string.Empty;
72
+ }
73
+ }
74
+ '@ -ReferencedAssemblies System.Windows.Forms
75
+
76
+ $result = [FolderPicker]::Pick($Title)
77
+ if ($result) { Write-Output $result }