Skip to content

Commit c2541ca

Browse files
committed
feat(memory): browser compatibility + cross-platform crypto + string I/O
- Replace all node:crypto imports with cross-platform sha256/uuid utilities - Guard fs/path/os imports behind runtime checks for browser safety - Add importFromString/exportToString for browser JSON/CSV I/O - Core memory operations (remember/recall/forget/health) now work in browsers - 538 tests passing, 0 TypeScript errors
1 parent d384d7e commit c2541ca

18 files changed

Lines changed: 302 additions & 90 deletions

src/memory/CognitiveMemoryManager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* @module agentos/memory/CognitiveMemoryManager
1111
*/
1212

13-
import crypto from 'node:crypto';
13+
import { uuid } from './util/crossPlatformCrypto.js';
1414

1515
import type {
1616
MemoryTrace,
@@ -178,12 +178,12 @@ export interface ICognitiveMemoryManager {
178178
// ---------------------------------------------------------------------------
179179

180180
/**
181-
* Generate a globally unique trace ID using crypto.randomUUID().
181+
* Generate a globally unique trace ID.
182182
* Previous implementation used a monotonic counter (`mt_{timestamp}_{counter}`)
183183
* which could collide across multiple processes or rapid restarts.
184184
*/
185185
function generateTraceId(): string {
186-
return `mt_${crypto.randomUUID()}`;
186+
return `mt_${uuid()}`;
187187
}
188188

189189
export class CognitiveMemoryManager implements ICognitiveMemoryManager {

src/memory/consolidation/ConsolidationLoop.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
* @module agentos/memory/consolidation/ConsolidationLoop
2525
*/
2626

27-
import crypto from 'node:crypto';
27+
import { sha256 } from '../util/crossPlatformCrypto.js';
2828
import type { ConsolidationResult, ExtendedConsolidationConfig } from '../facade/types.js';
2929
import type { SqliteBrain } from '../store/SqliteBrain.js';
3030
import type { IMemoryGraph } from '../graph/IMemoryGraph.js';
@@ -305,7 +305,7 @@ export class ConsolidationLoop {
305305

306306
for (const row of rows) {
307307
if (deletedIds.has(row.id)) continue;
308-
const hash = crypto.createHash('sha256').update(row.content).digest('hex');
308+
const hash = await sha256(row.content);
309309
const existing = hashMap.get(hash);
310310
if (existing && !deletedIds.has(existing.id)) {
311311
await this._mergeTracePair(existing, row, deletedIds);

src/memory/facade/Memory.ts

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@
1818
* @module memory/facade/Memory
1919
*/
2020

21-
import crypto from 'node:crypto';
22-
import fs from 'node:fs/promises';
23-
import os from 'node:os';
24-
import path from 'node:path';
21+
import { sha256 as crossSha256, uuid } from '../util/crossPlatformCrypto.js';
22+
23+
const _isNode = typeof process !== 'undefined' && !!process.versions?.node;
24+
25+
async function _getFs(): Promise<typeof import('node:fs/promises')> {
26+
if (!_isNode) throw new Error('Filesystem operations are not available in browser environments.');
27+
return import('node:fs/promises');
28+
}
29+
30+
async function _getPath(): Promise<typeof import('node:path')> {
31+
if (!_isNode) throw new Error('Path operations are not available in browser environments.');
32+
return import('node:path');
33+
}
34+
35+
async function _getOs(): Promise<typeof import('node:os')> {
36+
if (!_isNode) throw new Error('OS operations are not available in browser environments.');
37+
return import('node:os');
38+
}
2539

2640
import type { MemoryTrace } from '../types.js';
2741
import type { ITool } from '../../core/tools/ITool.js';
@@ -129,19 +143,19 @@ interface FtsJoinRow extends TraceRow {
129143
// ---------------------------------------------------------------------------
130144

131145
/**
132-
* Generate a globally unique, collision-free trace ID using crypto.randomUUID().
146+
* Generate a globally unique, collision-free trace ID.
133147
* Previous implementation used a monotonic counter (`mt_{timestamp}_{counter}`)
134148
* which could collide across multiple processes or rapid restarts.
135149
*/
136150
function nextTraceId(): string {
137-
return `mt_${crypto.randomUUID()}`;
151+
return `mt_${uuid()}`;
138152
}
139153

140154
/**
141155
* Compute SHA-256 hex digest of a string.
142156
*/
143-
function sha256(content: string): string {
144-
return crypto.createHash('sha256').update(content).digest('hex');
157+
async function sha256(content: string): Promise<string> {
158+
return crossSha256(content);
145159
}
146160

147161
// ---------------------------------------------------------------------------
@@ -275,9 +289,17 @@ export class Memory {
275289
static async create(config?: MemoryConfig): Promise<Memory> {
276290
// Step 1: merge with defaults.
277291
const randomSuffix = Math.random().toString(36).slice(2, 10);
292+
let defaultPath: string;
293+
if (_isNode) {
294+
const osModule = await _getOs();
295+
const pathModule = await _getPath();
296+
defaultPath = pathModule.join(osModule.tmpdir(), `brain-${randomSuffix}.sqlite`);
297+
} else {
298+
defaultPath = `brain-${randomSuffix}.sqlite`;
299+
}
278300
const merged = {
279301
store: 'sqlite' as const,
280-
path: path.join(os.tmpdir(), `brain-${randomSuffix}.sqlite`),
302+
path: defaultPath,
281303
graph: true,
282304
selfImprove: true,
283305
decay: true,
@@ -326,7 +348,7 @@ export class Memory {
326348
async remember(content: string, options?: RememberOptions): Promise<MemoryTrace> {
327349
await this._initPromise;
328350

329-
const contentHash = sha256(content);
351+
const contentHash = await sha256(content);
330352
const type = options?.type ?? 'episodic';
331353
const scope = options?.scope ?? 'user';
332354
const scopeId = options?.scopeId ?? '';
@@ -664,7 +686,8 @@ export class Memory {
664686

665687
try {
666688
// Detect source type.
667-
const stat = await fs.stat(source).catch(() => null);
689+
const fsModule = await _getFs();
690+
const stat = await fsModule.stat(source).catch(() => null);
668691

669692
if (stat?.isDirectory()) {
670693
// Directory scan.
@@ -925,7 +948,7 @@ export class Memory {
925948
async export(outputPath: string, options?: ExportOptions): Promise<void> {
926949
await this._initPromise;
927950

928-
const format = this._detectExportFormat(outputPath, options);
951+
const format = await this._detectExportFormat(outputPath, options);
929952

930953
switch (format) {
931954
case 'json': {
@@ -1001,6 +1024,47 @@ export class Memory {
10011024
return result;
10021025
}
10031026

1027+
/**
1028+
* Import memory data from a string without filesystem access.
1029+
*
1030+
* Supports JSON and CSV formats. Useful in browser environments or when
1031+
* the data is already in memory.
1032+
*
1033+
* @param content - The raw string content to import.
1034+
* @param format - The format of the content: `'json'` or `'csv'`.
1035+
* @returns Summary of the import operation.
1036+
*/
1037+
async importFromString(content: string, format: 'json' | 'csv'): Promise<ImportResult> {
1038+
await this._initPromise;
1039+
1040+
let result: ImportResult;
1041+
if (format === 'json') {
1042+
result = await new JsonImporter(this._brain).importFromString(content);
1043+
} else {
1044+
result = await new CsvImporter(this._brain).importFromString(content);
1045+
}
1046+
1047+
if (result.imported > 0) {
1048+
await this._rebuildFtsIndex();
1049+
}
1050+
1051+
return result;
1052+
}
1053+
1054+
/**
1055+
* Export the full brain state as a JSON string without filesystem access.
1056+
*
1057+
* Useful in browser environments or when the data needs to be sent over
1058+
* a network connection.
1059+
*
1060+
* @param options - Optional export configuration (embeddings, conversations).
1061+
* @returns Pretty-printed JSON string of the full brain payload.
1062+
*/
1063+
async exportToString(options?: ExportOptions): Promise<string> {
1064+
await this._initPromise;
1065+
return new JsonExporter(this._brain).exportToString(options);
1066+
}
1067+
10041068
// =========================================================================
10051069
// Tool integration
10061070
// =========================================================================
@@ -1300,7 +1364,7 @@ export class Memory {
13001364
},
13011365
result: IngestResult,
13021366
): Promise<void> {
1303-
const contentHash = sha256(doc.content);
1367+
const contentHash = await sha256(doc.content);
13041368
const existingDoc = await this._brain.get<{ id: string }>(
13051369
`SELECT id FROM documents WHERE content_hash = ? LIMIT 1`,
13061370
[contentHash],
@@ -1311,7 +1375,7 @@ export class Memory {
13111375
}
13121376

13131377
const chunks = await this._chunkingEngine.chunk(doc.content, chunking);
1314-
const docId = `doc_${crypto.randomUUID()}`;
1378+
const docId = `doc_${uuid()}`;
13151379

13161380
await this._brain.run(
13171381
`INSERT INTO documents
@@ -1330,7 +1394,7 @@ export class Memory {
13301394
);
13311395

13321396
for (const chunk of chunks) {
1333-
const chunkId = `chunk_${crypto.randomUUID()}`;
1397+
const chunkId = `chunk_${uuid()}`;
13341398
const traceId = nextTraceId();
13351399
const createdAt = Date.now();
13361400

@@ -1349,7 +1413,7 @@ export class Memory {
13491413
document_id: docId,
13501414
chunk_index: chunk.index,
13511415
},
1352-
{ contentHash: sha256(chunk.content) },
1416+
{ contentHash: await sha256(chunk.content) },
13531417
),
13541418
),
13551419
],
@@ -1402,13 +1466,14 @@ export class Memory {
14021466
/**
14031467
* Detect the export format from options or file extension.
14041468
*/
1405-
private _detectExportFormat(
1469+
private async _detectExportFormat(
14061470
outputPath: string,
14071471
options?: ExportOptions,
1408-
): 'json' | 'markdown' | 'obsidian' | 'sqlite' {
1472+
): Promise<'json' | 'markdown' | 'obsidian' | 'sqlite'> {
14091473
if (options?.format) return options.format;
14101474

1411-
const ext = path.extname(outputPath).toLowerCase();
1475+
const pathModule = await _getPath();
1476+
const ext = pathModule.extname(outputPath).toLowerCase();
14121477
switch (ext) {
14131478
case '.json': return 'json';
14141479
case '.sqlite':
@@ -1426,12 +1491,14 @@ export class Memory {
14261491
): Promise<'json' | 'markdown' | 'obsidian' | 'sqlite' | 'chatgpt' | 'csv'> {
14271492
if (options?.format && options.format !== 'auto') return options.format;
14281493

1429-
const ext = path.extname(source).toLowerCase();
1494+
const pathModule = await _getPath();
1495+
const ext = pathModule.extname(source).toLowerCase();
14301496
switch (ext) {
14311497
case '.json': {
14321498
// Check if it looks like a ChatGPT export.
14331499
try {
1434-
const head = await fs.readFile(source, { encoding: 'utf8', flag: 'r' });
1500+
const fsModule = await _getFs();
1501+
const head = await fsModule.readFile(source, { encoding: 'utf8', flag: 'r' });
14351502
if (head.includes('"mapping"') && head.includes('"conversation_id"')) {
14361503
return 'chatgpt';
14371504
}
@@ -1444,7 +1511,8 @@ export class Memory {
14441511
default: {
14451512
// Check if source is a directory.
14461513
try {
1447-
const stat = await fs.stat(source);
1514+
const fsModule = await _getFs();
1515+
const stat = await fsModule.stat(source);
14481516
if (stat.isDirectory()) return 'markdown';
14491517
} catch { /* fall through */ }
14501518
return 'json';

src/memory/io/ChatGptImporter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@
3535
* @module memory/io/ChatGptImporter
3636
*/
3737

38-
import fs from 'node:fs/promises';
39-
import crypto from 'node:crypto';
38+
import { sha256 } from '../util/crossPlatformCrypto.js';
4039
import { v4 as uuidv4 } from 'uuid';
4140
import type { ImportResult } from '../facade/types.js';
4241
import type { SqliteBrain } from '../store/SqliteBrain.js';
@@ -115,6 +114,7 @@ export class ChatGptImporter {
115114
// ---- Load + parse ----
116115
let raw: string;
117116
try {
117+
const fs = await import('node:fs/promises');
118118
raw = await fs.readFile(sourcePath, 'utf8');
119119
} catch (err) {
120120
result.errors.push(`Failed to read file: ${String(err)}`);
@@ -282,7 +282,7 @@ export class ChatGptImporter {
282282
conversationId: string,
283283
result: ImportResult,
284284
): Promise<void> {
285-
const hash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
285+
const hash = await sha256(content);
286286

287287
// Dedup check.
288288
const { dialect } = this.brain.features;

src/memory/io/CsvImporter.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@
2323
* @module memory/io/CsvImporter
2424
*/
2525

26-
import crypto from 'node:crypto';
27-
import fs from 'node:fs/promises';
26+
import { sha256 } from '../util/crossPlatformCrypto.js';
2827
import { v4 as uuidv4 } from 'uuid';
2928
import type { ImportResult } from '../facade/types.js';
3029
import type { SqliteBrain } from '../store/SqliteBrain.js';
@@ -46,12 +45,35 @@ export class CsvImporter {
4645

4746
let raw: string;
4847
try {
48+
const fs = await import('node:fs/promises');
4949
raw = await fs.readFile(sourcePath, 'utf8');
5050
} catch (err) {
5151
result.errors.push(`Failed to read file: ${String(err)}`);
5252
return result;
5353
}
5454

55+
return this._importCsvContent(raw, result);
56+
}
57+
58+
/**
59+
* Import a CSV string directly into the target brain without filesystem access.
60+
*
61+
* @param csvContent - The raw CSV string to parse and import.
62+
* @returns Import summary with imported/skipped/error counts.
63+
*/
64+
async importFromString(csvContent: string): Promise<ImportResult> {
65+
const result: ImportResult = { imported: 0, skipped: 0, errors: [] };
66+
return this._importCsvContent(csvContent, result);
67+
}
68+
69+
/**
70+
* Parse raw CSV content and import its rows into the brain.
71+
*
72+
* @param raw - The raw CSV string (may include BOM).
73+
* @param result - Mutable `ImportResult` to accumulate counts.
74+
* @returns The populated `ImportResult`.
75+
*/
76+
private async _importCsvContent(raw: string, result: ImportResult): Promise<ImportResult> {
5577
const rows = this._parseCsv(raw.replace(/^\uFEFF/, ''));
5678
if (rows.length === 0) {
5779
result.errors.push('CSV import failed: file is empty.');
@@ -100,7 +122,7 @@ export class CsvImporter {
100122
continue;
101123
}
102124

103-
const hash = this._sha256(content);
125+
const hash = await this._sha256(content);
104126
const existing = await trx.get<{ id: string }>(checkSql, [hash, hash]);
105127
if (existing) {
106128
result.skipped++;
@@ -150,8 +172,8 @@ export class CsvImporter {
150172
return result;
151173
}
152174

153-
private _sha256(content: string): string {
154-
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
175+
private async _sha256(content: string): Promise<string> {
176+
return sha256(content);
155177
}
156178

157179
private _readCell(row: string[], index: number): string {

0 commit comments

Comments
 (0)