@openanonymity/nanomem 0.1.0 → 0.1.2

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -18
  3. package/package.json +7 -3
  4. package/src/backends/BaseStorage.js +147 -3
  5. package/src/backends/indexeddb.js +21 -8
  6. package/src/browser.js +227 -0
  7. package/src/bullets/parser.js +8 -9
  8. package/src/cli/auth.js +1 -1
  9. package/src/cli/commands.js +58 -9
  10. package/src/cli/config.js +1 -1
  11. package/src/cli/help.js +5 -2
  12. package/src/cli/output.js +4 -0
  13. package/src/cli.js +6 -3
  14. package/src/engine/compactor.js +3 -6
  15. package/src/engine/deleter.js +187 -0
  16. package/src/engine/executors.js +474 -11
  17. package/src/engine/ingester.js +98 -63
  18. package/src/engine/recentConversation.js +110 -0
  19. package/src/engine/retriever.js +243 -37
  20. package/src/engine/toolLoop.js +51 -9
  21. package/src/imports/chatgpt.js +1 -1
  22. package/src/imports/claude.js +85 -0
  23. package/src/imports/importData.js +462 -0
  24. package/src/imports/index.js +10 -0
  25. package/src/index.js +95 -2
  26. package/src/llm/openai.js +204 -58
  27. package/src/llm/tinfoil.js +508 -0
  28. package/src/omf.js +343 -0
  29. package/src/prompt_sets/conversation/ingestion.js +111 -12
  30. package/src/prompt_sets/document/ingestion.js +98 -4
  31. package/src/prompt_sets/index.js +12 -4
  32. package/src/types.js +135 -4
  33. package/src/vendor/tinfoil.browser.d.ts +2 -0
  34. package/src/vendor/tinfoil.browser.js +41596 -0
  35. package/types/backends/BaseStorage.d.ts +19 -0
  36. package/types/backends/indexeddb.d.ts +1 -0
  37. package/types/browser.d.ts +17 -0
  38. package/types/engine/deleter.d.ts +67 -0
  39. package/types/engine/executors.d.ts +56 -2
  40. package/types/engine/recentConversation.d.ts +18 -0
  41. package/types/engine/retriever.d.ts +22 -9
  42. package/types/imports/claude.d.ts +14 -0
  43. package/types/imports/importData.d.ts +29 -0
  44. package/types/imports/index.d.ts +2 -0
  45. package/types/index.d.ts +9 -0
  46. package/types/llm/openai.d.ts +6 -9
  47. package/types/llm/tinfoil.d.ts +13 -0
  48. package/types/omf.d.ts +40 -0
  49. package/types/prompt_sets/conversation/ingestion.d.ts +8 -3
  50. package/types/prompt_sets/document/ingestion.d.ts +8 -3
  51. package/types/types.d.ts +127 -2
  52. package/types/vendor/tinfoil.browser.d.ts +6348 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Open Anonymity Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @openanonymity/nanomem
2
2
 
3
+ ```
4
+ __ __ ______ __ __ ______ __ __ ______ __ __
5
+ /\ "-.\ \ /\ __ \ /\ "-.\ \ /\ __ \ /\ "-./ \ /\ ___\ /\ "-./ \
6
+ \ \ \-. \ \ \ __ \ \ \ \-. \ \ \ \/\ \ \ \ \-./\ \ \ \ __\ \ \ \-./\ \
7
+ \ \_\\"\_\ \ \_\ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \ \_\ \ \_____\ \ \_\ \ \_\
8
+ \/_/ \/_/ \/_/\/_/ \/_/ \/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/ \/_/
9
+ ```
10
+
3
11
  **Personal memory you own, in files you can actually read.**
4
12
 
5
13
  `nanomem` turns chats, notes, and exports into a markdown memory system that an LLM can update and retrieve as facts evolve over time. The result stays inspectable, portable, and user-owned instead of disappearing into hidden vector state.
@@ -23,7 +31,8 @@ Retrieval is only one part of memory. `nanomem` is built for the maintenance lay
23
31
  - **Evolving memory state.** Keep facts current as they change over time instead of treating memory as an append-only log.
24
32
  - **Compaction and cleanup.** Collapse repeated signals into stable knowledge and move stale memory into history.
25
33
  - **Conflict-aware updates.** Resolve outdated or contradictory facts using recency, source, and confidence.
26
- - **Import your existing history.** Start from ChatGPT exports, [OA Chat](https://chat.openanonymity.ai) exports, transcripts, message arrays, markdown notes, or whole markdown directories.
34
+ - **Import your existing history.** Start from ChatGPT exports, Claude exports, [OA Chat](https://chat.openanonymity.ai) exports, transcripts, message arrays, markdown notes, or whole markdown directories.
35
+ - **Portable memory exchange.** Export full memory state as plain text, ZIP, or Open Memory Format (OMF), and merge OMF documents back in programmatically.
27
36
  - **Flexible storage.** Run on local files, IndexedDB, in-memory storage, or a custom backend.
28
37
  - **Built to plug in.** Use it from the CLI, as a library, or as a memory layer for other agents.
29
38
 
@@ -32,7 +41,7 @@ Retrieval is only one part of memory. `nanomem` is built for the maintenance lay
32
41
  Install:
33
42
 
34
43
  ```bash
35
- npm install -g @openanonymity/nanomem
44
+ npm i @openanonymity/nanomem
36
45
  ```
37
46
 
38
47
  Set up once:
@@ -43,6 +52,13 @@ nanomem login
43
52
 
44
53
  This walks you through provider, model, API key, and where to store your memory. Config is saved to `~/.config/nanomem/config.json`. Filesystem memory lives in `~/nanomem/` by default.
45
54
 
55
+ Add facts directly:
56
+
57
+ ```bash
58
+ nanomem add "I moved to Seattle and started a new job at Acme."
59
+ nanomem update "Actually I moved to Portland, not Seattle."
60
+ ```
61
+
46
62
  Import history or notes:
47
63
 
48
64
  ```bash
@@ -58,6 +74,13 @@ nanomem retrieve "what are my hobbies?"
58
74
  nanomem retrieve "what are my hobbies?" --render
59
75
  ```
60
76
 
77
+ Delete facts from memory:
78
+
79
+ ```bash
80
+ nanomem delete "I have a dog named Mochi"
81
+ nanomem delete "I have a dog named Mochi" --deep
82
+ ```
83
+
61
84
  Compact and clean up memory:
62
85
 
63
86
  ```bash
@@ -73,6 +96,17 @@ nanomem login --provider anthropic --api-key sk-ant-... --model claude-sonnet-4-
73
96
 
74
97
  Supported providers include OpenAI, Anthropic, Tinfoil, OpenRouter, and OpenAI-compatible endpoints via `--base-url`.
75
98
 
99
+ When `provider` is `tinfoil`, nanomem now uses the Tinfoil SDK and fails
100
+ closed on enclave attestation verification before any inference request is
101
+ sent. Browser consumers load a vendored SDK bundle, construct `TinfoilAI`,
102
+ and require `await client.getVerificationDocument()` to report
103
+ `securityVerified === true` before inference. The vendored bundle lives at
104
+ `src/vendor/tinfoil.browser.js`; refresh it after SDK upgrades with:
105
+
106
+ ```bash
107
+ npm run vendor:tinfoil
108
+ ```
109
+
76
110
  ## How it works
77
111
 
78
112
  ```text
@@ -89,7 +123,9 @@ conversation / notes / exports
89
123
  | memory retrieve
90
124
  | file selection + bullet-level scoring
91
125
  v
92
- assembled memory context
126
+ prompt crafting / retrieval
127
+ retrieve -> augment_query(user_query, memory_files)
128
+ -> minimized reviewable prompt
93
129
  |
94
130
  v
95
131
  memory compact
@@ -109,16 +145,13 @@ Memory is stored as markdown with structured metadata:
109
145
  ```md
110
146
  # Memory: Work
111
147
 
112
- ## Working
113
- ### Current context
148
+ ## Working memory (current context subject to change)
114
149
  - Preparing for a product launch next month | topic=work | tier=working | status=active | source=user_statement | confidence=high | updated_at=2026-04-07 | review_at=2026-04-20
115
150
 
116
- ## Long-Term
117
- ### Stable facts
151
+ ## Long-term memory (stable facts that are unlikely to change)
118
152
  - Leads the backend team at Acme | topic=work | tier=long_term | status=active | source=user_statement | confidence=high | updated_at=2026-04-07
119
153
 
120
- ## History
121
- ### No longer current
154
+ ## History (no longer current)
122
155
  - Previously lived in New York | topic=personal | tier=history | status=superseded | source=user_statement | confidence=high | updated_at=2024-06-01
123
156
  ```
124
157
 
@@ -132,7 +165,7 @@ import { createMemoryBank } from '@openanonymity/nanomem';
132
165
  const memory = createMemoryBank({
133
166
  llm: { apiKey: 'sk-...', model: 'gpt-5.4-mini' },
134
167
  storage: 'filesystem',
135
- storagePath: './memory'
168
+ storagePath: '~/nanomem'
136
169
  });
137
170
 
138
171
  await memory.init();
@@ -144,17 +177,25 @@ await memory.ingest([
144
177
 
145
178
  const result = await memory.retrieve('Where do I live now?');
146
179
  await memory.compact();
180
+
181
+ const omf = await memory.exportOmf();
182
+ const preview = await memory.previewOmfImport(omf);
183
+ await memory.importOmf(omf);
147
184
  ```
148
185
 
149
186
  ## Common commands
150
187
 
151
188
  ```bash
152
- nanomem import <file|dir|->
153
- nanomem retrieve <query> [--context <file>]
154
- nanomem tree
155
- nanomem compact
156
- nanomem export --format zip
157
- nanomem status
189
+ nanomem add <text> # add new facts
190
+ nanomem update <text> # correct existing facts
191
+ nanomem delete <query> # delete facts matching a query
192
+ nanomem delete <query> --deep # delete across all files (thorough)
193
+ nanomem import <file|dir|-> # import history or notes
194
+ nanomem retrieve <query> [--context <file>] # retrieve relevant context
195
+ nanomem tree # browse memory files
196
+ nanomem compact # deduplicate and archive
197
+ nanomem export --format zip # export everything
198
+ nanomem status # show config and stats
158
199
  ```
159
200
 
160
201
  For terminal use, `--render` will format markdown-heavy output like `read` and `retrieve` into a more readable ANSI-rendered view while leaving `--json` and piped output unchanged.
@@ -163,7 +204,8 @@ For terminal use, `--render` will format markdown-heavy output like `read` and `
163
204
 
164
205
  `nanomem import` supports:
165
206
 
166
- - ChatGPT exports
207
+ - ChatGPT exports (`conversations.json` from "Export data")
208
+ - Claude exports (`conversations.json` from "Export data")
167
209
  - [OA Chat](https://chat.openanonymity.ai) exports
168
210
  - markdown notes
169
211
  - recursive markdown directory imports
@@ -173,7 +215,9 @@ For terminal use, `--render` will format markdown-heavy output like `read` and `
173
215
  Import can operate in both conversation-oriented and document-oriented modes, depending on the source or explicit flags.
174
216
 
175
217
  ```bash
176
- nanomem import conversations.json # conversation mode
218
+ nanomem import conversations.json # auto-detects ChatGPT or Claude format
219
+ nanomem import conversations.json --format claude # explicit Claude format
220
+ nanomem import conversations.json --format chatgpt # explicit ChatGPT format
177
221
  nanomem import ./notes/ # document mode (auto for directories)
178
222
  nanomem import my-notes.md --format markdown # document mode (explicit)
179
223
  ```
@@ -189,6 +233,8 @@ nanomem import my-notes.md --format markdown # document mode (explicit)
189
233
 
190
234
  Internals: [docs/memory-system.md](./docs/memory-system.md)
191
235
 
236
+ OMF spec: [docs/omf.md](./docs/omf.md)
237
+
192
238
  ## License
193
239
 
194
240
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openanonymity/nanomem",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "LLM-driven personal memory with agentic retrieval, extraction, and compaction",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,9 @@
17
17
  "url": "https://github.com/openanonymity/nanomem/issues"
18
18
  },
19
19
  "scripts": {
20
- "prepublishOnly": "npm run build:types",
20
+ "test": "node --test test/**/*.test.js",
21
+ "vendor:tinfoil": "node scripts/vendor-tinfoil.mjs",
22
+ "prepublishOnly": "npm run vendor:tinfoil && npm run build:types",
21
23
  "build:types": "tsc",
22
24
  "prepack": "tsc"
23
25
  },
@@ -72,10 +74,12 @@
72
74
  "license": "MIT",
73
75
  "devDependencies": {
74
76
  "@types/node": "^25.5.2",
77
+ "esbuild": "^0.27.2",
75
78
  "typescript": "^6.0.2"
76
79
  },
77
80
  "dependencies": {
78
- "@pierre/diffs": "^1.1.12"
81
+ "@pierre/diffs": "^1.1.12",
82
+ "tinfoil": "^1.1.3"
79
83
  },
80
84
  "directories": {
81
85
  "doc": "docs"
@@ -20,7 +20,7 @@
20
20
  * getTree() → string
21
21
  */
22
22
  /** @import { ExportRecord, ListResult, SearchResult, StorageMetadata } from '../types.js' */
23
- import { parseBullets, extractTitles, countBullets } from '../bullets/index.js';
23
+ import { parseBullets, extractTitles, countBullets, normalizeFactText } from '../bullets/index.js';
24
24
 
25
25
  export class BaseStorage {
26
26
 
@@ -42,7 +42,41 @@ export class BaseStorage {
42
42
  * @returns {Promise<string | null>}
43
43
  */
44
44
  async read(path) {
45
- return this._readRaw(path);
45
+ const requestedPath = this._normalizeRequestedPath(path);
46
+ if (!requestedPath) return null;
47
+
48
+ const exact = await this._readRaw(requestedPath);
49
+ if (exact !== null) {
50
+ return exact;
51
+ }
52
+
53
+ const resolvedPath = await this._resolveReadablePath(requestedPath);
54
+ if (!resolvedPath || resolvedPath === requestedPath) {
55
+ return null;
56
+ }
57
+
58
+ return this._readRaw(resolvedPath);
59
+ }
60
+
61
+ /**
62
+ * Resolve a user/model-supplied path to the canonical readable path.
63
+ *
64
+ * Returns the exact stored path when possible, or a normalized fallback
65
+ * match when the requested path is only approximately correct.
66
+ *
67
+ * @param {string} path
68
+ * @returns {Promise<string | null>}
69
+ */
70
+ async resolvePath(path) {
71
+ const requestedPath = this._normalizeRequestedPath(path);
72
+ if (!requestedPath) return null;
73
+
74
+ const exact = await this._readRaw(requestedPath);
75
+ if (exact !== null) {
76
+ return requestedPath;
77
+ }
78
+
79
+ return this._resolveReadablePath(requestedPath);
46
80
  }
47
81
 
48
82
  /**
@@ -135,7 +169,10 @@ export class BaseStorage {
135
169
  /** Override for efficient path listing. Default uses exportAll(). */
136
170
  async _listAllPaths() {
137
171
  const all = await this.exportAll();
138
- return all.map(r => r.path);
172
+ return all
173
+ .filter((record) => typeof record?.path === 'string')
174
+ .filter((record) => this._isInternalPath(record.path) || typeof record?.content === 'string')
175
+ .map((record) => record.path);
139
176
  }
140
177
 
141
178
  _parentPath(filePath) {
@@ -143,6 +180,113 @@ export class BaseStorage {
143
180
  return lastSlash === -1 ? '' : filePath.slice(0, lastSlash);
144
181
  }
145
182
 
183
+ _basenamePath(filePath) {
184
+ const normalized = this._normalizeRequestedPath(filePath);
185
+ if (!normalized) return '';
186
+ const lastSlash = normalized.lastIndexOf('/');
187
+ return lastSlash === -1 ? normalized : normalized.slice(lastSlash + 1);
188
+ }
189
+
190
+ _normalizeRequestedPath(path) {
191
+ return String(path || '')
192
+ .trim()
193
+ .replace(/\\/g, '/')
194
+ .replace(/^\.\//, '')
195
+ .replace(/^\/+/, '')
196
+ .replace(/\/+/g, '/');
197
+ }
198
+
199
+ _normalizeLookupKey(path, { stripExtension = false } = {}) {
200
+ let normalized = this._normalizeRequestedPath(path);
201
+ if (!normalized) return '';
202
+
203
+ if (stripExtension) {
204
+ normalized = normalized.replace(/\.md$/i, '');
205
+ }
206
+
207
+ if (typeof normalized.normalize === 'function') {
208
+ normalized = normalized.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
209
+ }
210
+
211
+ return normalizeFactText(normalized.replace(/[\/_]/g, ' '));
212
+ }
213
+
214
+ async _listReadablePaths() {
215
+ const all = await this.exportAll();
216
+ return all
217
+ .filter((record) => typeof record?.path === 'string')
218
+ .filter((record) => !this._isInternalPath(record.path))
219
+ .filter((record) => typeof record?.content === 'string')
220
+ .map((record) => record.path);
221
+ }
222
+
223
+ async _resolveReadablePath(path) {
224
+ const requestedPath = this._normalizeRequestedPath(path);
225
+ if (!requestedPath) return null;
226
+
227
+ const readablePaths = await this._listReadablePaths();
228
+ if (readablePaths.length === 0) return null;
229
+
230
+ const fullKey = this._normalizeLookupKey(requestedPath);
231
+ const extlessKey = this._normalizeLookupKey(requestedPath, { stripExtension: true });
232
+
233
+ const fullMatches = readablePaths.filter((candidate) => this._normalizeLookupKey(candidate) === fullKey);
234
+ if (fullMatches.length > 0) {
235
+ return this._choosePreferredPath(fullMatches, requestedPath);
236
+ }
237
+
238
+ const extlessMatches = readablePaths.filter((candidate) => this._normalizeLookupKey(candidate, { stripExtension: true }) === extlessKey);
239
+ if (extlessMatches.length > 0) {
240
+ return this._choosePreferredPath(extlessMatches, requestedPath);
241
+ }
242
+
243
+ const basenameKey = this._normalizeLookupKey(this._basenamePath(requestedPath), { stripExtension: true });
244
+ if (!basenameKey) return null;
245
+
246
+ const basenameMatches = readablePaths.filter((candidate) => (
247
+ this._normalizeLookupKey(this._basenamePath(candidate), { stripExtension: true }) === basenameKey
248
+ ));
249
+ if (basenameMatches.length > 0) {
250
+ return this._choosePreferredPath(basenameMatches, requestedPath);
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ _choosePreferredPath(candidates, requestedPath) {
257
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
258
+ if (candidates.length === 1) return candidates[0];
259
+
260
+ const requestedParent = this._normalizeLookupKey(this._parentPath(requestedPath));
261
+ const requestedBase = this._normalizeLookupKey(this._basenamePath(requestedPath), { stripExtension: true });
262
+
263
+ return [...candidates]
264
+ .sort((left, right) => {
265
+ const leftScore = this._pathMatchScore(left, requestedParent, requestedBase);
266
+ const rightScore = this._pathMatchScore(right, requestedParent, requestedBase);
267
+ if (leftScore !== rightScore) return rightScore - leftScore;
268
+ if (left.length !== right.length) return left.length - right.length;
269
+ return left.localeCompare(right);
270
+ })[0];
271
+ }
272
+
273
+ _pathMatchScore(candidate, requestedParent, requestedBase) {
274
+ let score = 0;
275
+ if (requestedParent) {
276
+ if (this._normalizeLookupKey(this._parentPath(candidate)) === requestedParent) {
277
+ score += 4;
278
+ }
279
+ } else if (!this._parentPath(candidate)) {
280
+ score += 1;
281
+ }
282
+
283
+ if (requestedBase && this._normalizeLookupKey(this._basenamePath(candidate), { stripExtension: true }) === requestedBase) {
284
+ score += 2;
285
+ }
286
+
287
+ return score;
288
+ }
289
+
146
290
  /** Generate a one-line summary of file content for the index. */
147
291
  _generateOneLiner(content) {
148
292
  if (!content) return '';
@@ -45,6 +45,9 @@ class IndexedDBStorage extends BaseStorage {
45
45
  try { await this._bootstrap(); } catch (err) {
46
46
  console.warn('[IndexedDBStorage] Init error:', err);
47
47
  }
48
+ try { await this.rebuildTree(); } catch (err) {
49
+ console.warn('[IndexedDBStorage] Tree rebuild error:', err);
50
+ }
48
51
  resolve(/** @type {IDBDatabase} */ (this.db));
49
52
  };
50
53
 
@@ -100,9 +103,9 @@ class IndexedDBStorage extends BaseStorage {
100
103
 
101
104
  await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
102
105
  const tx = /** @type {IDBDatabase} */ (this.db).transaction(STORE_NAME, 'readwrite');
103
- const request = tx.objectStore(STORE_NAME).put(record);
104
- request.onsuccess = () => resolve();
105
- request.onerror = () => reject(request.error);
106
+ tx.objectStore(STORE_NAME).put(record);
107
+ tx.oncomplete = () => resolve();
108
+ tx.onerror = () => reject(tx.error);
106
109
  }));
107
110
  }
108
111
 
@@ -116,9 +119,9 @@ class IndexedDBStorage extends BaseStorage {
116
119
 
117
120
  await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
118
121
  const tx = /** @type {IDBDatabase} */ (this.db).transaction(STORE_NAME, 'readwrite');
119
- const request = tx.objectStore(STORE_NAME).delete(path);
120
- request.onsuccess = () => resolve();
121
- request.onerror = () => reject(request.error);
122
+ tx.objectStore(STORE_NAME).delete(path);
123
+ tx.oncomplete = () => resolve();
124
+ tx.onerror = () => reject(tx.error);
122
125
  }));
123
126
  await this.rebuildTree();
124
127
  }
@@ -153,7 +156,7 @@ class IndexedDBStorage extends BaseStorage {
153
156
  /** @returns {Promise<void>} */
154
157
  async rebuildTree() {
155
158
  await this.init();
156
- const all = await this._getAll();
159
+ const all = this._sanitizeRecords(await this._getAll());
157
160
  const files = all
158
161
  .filter((r) => !this._isInternalPath(r.path))
159
162
  .sort((a, b) => a.path.localeCompare(b.path));
@@ -181,7 +184,7 @@ class IndexedDBStorage extends BaseStorage {
181
184
  /** @returns {Promise<ExportRecord[]>} */
182
185
  async exportAll() {
183
186
  await this.init();
184
- return this._getAll();
187
+ return this._sanitizeRecords(await this._getAll());
185
188
  }
186
189
 
187
190
  // ─── Internal IndexedDB helpers ──────────────────────────────
@@ -203,6 +206,16 @@ class IndexedDBStorage extends BaseStorage {
203
206
  request.onerror = () => reject(request.error);
204
207
  });
205
208
  }
209
+
210
+ _sanitizeRecords(records) {
211
+ return (records || [])
212
+ .filter((record) => typeof record?.path === 'string' && record.path.trim())
213
+ .map((record) => ({
214
+ ...record,
215
+ path: record.path.trim()
216
+ }))
217
+ .filter((record) => this._isInternalPath(record.path) || typeof record?.content === 'string');
218
+ }
206
219
  }
207
220
 
208
221
  export { IndexedDBStorage };