Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable documentation and user-facing behavior changes are tracked in this f

### Added

- Added **Copy and Move Note Between Workspaces and Folders** (`File → Copy Note / Move Note`).
- Allows copying or moving notes across workspaces and subfolders directly from top **File** menu submenus (`To Workspace ▶`, `To Folder (Current Workspace) ▶`) or transfer dialog.
- Automatically copies/moves embedded local media assets (`media/images/`, `media/uploads/`) and diagrams (`.notes-app/excali-diagrams/`, `.notes-app/drawio-diagrams/`).
- Recalculates relative link paths in Markdown text to match target directory depth without breaking image/diagram references in the note or other notes.
- Added unit test suite `src/tests/utils/noteMover.test.js` covering workspace transfer, duplicate indexing, subfolder relocation, and relative link path rewriting.
- Added modular, local-first **AI Platform** overhaul:
- **Global AI Chat**: Open AI Assistant panel from the left sidebar on the landing screen to search/chat across the entire workspace.
- **Sourced References**: Render referred notes chips under assistant message bubbles so users can inspect note relevance.
Expand Down
53 changes: 50 additions & 3 deletions ai/embeddings/ONNXEmbedder.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,50 @@ const { createLogger } = require('../core/logger');
const log = createLogger('ONNXEmbedder');

class ONNXEmbedder {
constructor(appDataDir) {
constructor(appDataDir, idleTimeoutMs = 5 * 60 * 1000) {
this.modelDir = path.join(appDataDir, 'notely', 'ai-model');
this.session = null;
this.tokenizer = null;
this.isLoaded = false;
this.ort = null;
this.isInitialized = false;
this.idleTimeoutMs = idleTimeoutMs;
this.idleTimer = null;
this.activeRequests = 0;
}

resetIdleTimer() {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.idleTimeoutMs > 0 && this.isLoaded && this.activeRequests === 0) {
this.idleTimer = setTimeout(() => {
this.unload().catch(err => log.error('Failed to idle unload ONNX session', err));
}, this.idleTimeoutMs);
}
}

async unload() {
if (this.activeRequests > 0) {
this.resetIdleTimer();
return;
}
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
if (this.session) {
try {
if (typeof this.session.release === 'function') {
await this.session.release().catch(() => {});
}
} catch { /* ignore session release error */ }
this.session = null;
}
this.vocab = null;
this.isLoaded = false;
log.info('ONNX embedding model unloaded from memory due to idle timeout.');
}

async load() {
Expand Down Expand Up @@ -39,6 +76,7 @@ class ONNXEmbedder {
this.vocab = fs.readFileSync(vocabPath, 'utf8').split('\n');
this.isLoaded = true;
this.isInitialized = true;
this.resetIdleTimer();
log.info('ONNX embedding model loaded successfully.');
} catch (err) {
this.isInitialized = false;
Expand Down Expand Up @@ -68,11 +106,17 @@ class ONNXEmbedder {
* @returns {Promise<Array<number>>}
*/
async generateEmbedding(text) {
if (!this.isLoaded) {
await this.load();
this.activeRequests++;
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}

try {
if (!this.isLoaded || !this.session) {
await this.load();
}

const tokens = this.tokenize(text);
const inputIds = new BigInt64Array(tokens.map(t => BigInt(t)));
const attentionMask = new BigInt64Array(tokens.map(() => 1n));
Expand Down Expand Up @@ -109,6 +153,9 @@ class ONNXEmbedder {
} catch (err) {
log.error('Embedding generation failed', err);
throw err;
} finally {
this.activeRequests = Math.max(0, this.activeRequests - 1);
this.resetIdleTimer();
}
}

Expand Down
2 changes: 1 addition & 1 deletion ai/graph/EntityResolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class EntityResolver {
const existing = this.graphDb.db.prepare('SELECT id, name, canonical_name, type FROM entities WHERE id = ?').get(bestMatch);
if (existing) {
log.info(`Vector concept merged "${clean}" -> "${existing.canonical_name}" (similarity: ${highestSimilarity.toFixed(3)})`);
this.addAlias(clean, existing.id, parseFloat(highestSimilarity.toFixed(3)));
this.addAlias(existing.id, clean, parseFloat(highestSimilarity.toFixed(3)));
return {
id: existing.id,
name: existing.name,
Expand Down
2 changes: 2 additions & 0 deletions ai/graph/GraphBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ class GraphBuilder {
? this.graphService.entityResolver.generateEntityId(rel.target_name, rel.target_type || 'Entity')
: `ent-${item.source.sourceType()}-${String(rel.target_name).toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
if (srcId !== tgtId) {
this.graphDb.upsertEntity({ id: srcId, name: rel.source_name, canonical_name: rel.source_name, type: rel.source_type || 'Entity' });
this.graphDb.upsertEntity({ id: tgtId, name: rel.target_name, canonical_name: rel.target_name, type: rel.target_type || 'Entity' });
this.graphDb.upsertRelationship({ source_id: srcId, target_id: tgtId, type: rel.type, weight: rel.weight, confidence: rel.confidence });
}
}
Expand Down
2 changes: 1 addition & 1 deletion ai/graph/GraphDB.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ class GraphDB {
*/
runTransaction(fn) {
if (!this.db) throw new Error('Database not initialized');
this.db.exec('BEGIN;');
this.db.exec('BEGIN IMMEDIATE;');
try {
const result = fn();
this.db.exec('COMMIT;');
Expand Down
16 changes: 12 additions & 4 deletions ai/graph/GraphMaintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ class GraphMaintenance {
*/
async runMaintenance() {
if (!this.graphDb?.db) return { purgedOrphans: 0, decayedEdges: 0 };
log.info('Starting background GraphMaintenance run...');
log.debug('Starting background GraphMaintenance run...');

const purgedOrphans = this.purgeOrphans();
const decayedEdges = this.decayStaleEdges();
const mergedAliases = this.deduplicateAliases();

log.info(`GraphMaintenance finished: Purged ${purgedOrphans} orphans, decayed ${decayedEdges} edges, merged ${mergedAliases} aliases.`);
if (purgedOrphans > 0 || decayedEdges > 0 || mergedAliases > 0) {
log.info(`GraphMaintenance finished: Purged ${purgedOrphans} orphans, decayed ${decayedEdges} edges, merged ${mergedAliases} aliases.`);
} else {
log.debug('GraphMaintenance finished with 0 modifications.');
}
return { purgedOrphans, decayedEdges, mergedAliases };
}

Expand Down Expand Up @@ -88,9 +92,13 @@ class GraphMaintenance {
const e2 = entities[j];
if (!e1 || !e2 || e1.id === e2.id || e1.type !== e2.type) continue;
if (SKIP_DEDUP_TYPES.has(e1.type)) continue;
if ((e1.name || '').length < 4 || (e2.name || '').length < 4) continue;
const n1 = (e1.name || '').trim();
const n2 = (e2.name || '').trim();
if (n1.length < 4 || n2.length < 4) continue;
if (Math.abs(n1.length - n2.length) > 4) continue;
if (n1[0].toLowerCase() !== n2[0].toLowerCase()) continue;

const sim = this.entityResolver.calculateSimilarity(e1.name, e2.name);
const sim = this.entityResolver.calculateSimilarity(n1, n2);
if (sim >= 0.88) {
// Determine survivor (canonical) and deprecated entity based on degree count
const deg1 = this._getEntityDegree(e1.id);
Expand Down
26 changes: 22 additions & 4 deletions ai/graph/GraphService.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,28 @@ class GraphService {
properties: ast.rootEntity.properties
});

// Remove stale outgoing relationships before deleting evidence to preserve FK integrity
// Remove stale relationships (outgoing note edges & concept edges tied to note evidence) before deleting evidence
if (this.graphDb?.db) {
this.graphDb.db.prepare('DELETE FROM relationships WHERE source_id = ?').run(rootEntityId);
try {
this.graphDb.db.prepare(`
DELETE FROM relationships
WHERE (source_id = ? AND extractor IN ('ast_parser', 'deterministic_miner', 'gliner2-relex'))
OR evidence_id IN (SELECT id FROM evidence WHERE source_id = ? AND extractor IN ('ast_parser', 'deterministic_miner', 'gliner2-relex'))
OR id IN (SELECT relationship_id FROM relationship_evidence re JOIN evidence e ON re.evidence_id = e.id WHERE e.source_id = ? AND e.extractor IN ('ast_parser', 'deterministic_miner', 'gliner2-relex'))
`).run(rootEntityId, filePath, filePath);
} catch {
try { this.graphDb.db.prepare("DELETE FROM relationships WHERE source_id = ? AND extractor IN ('ast_parser', 'deterministic_miner', 'gliner2-relex')").run(rootEntityId); } catch { /* ignore */ }
}
}
if (this.graphDb?.db) {
try {
this.graphDb.db.prepare("DELETE FROM evidence WHERE source_id = ? AND extractor IN ('ast_parser', 'deterministic_miner', 'gliner2-relex')").run(filePath);
} catch {
this.evidenceStore.deleteForSource(filePath);
}
} else {
this.evidenceStore.deleteForSource(filePath);
}
this.evidenceStore.deleteForSource(filePath);

// 1a. Wikilinks [[Target]]
for (const link of ast.links) {
Expand Down Expand Up @@ -435,8 +452,9 @@ class GraphService {
this._mentionIndexTime = Date.now();
}

const lowerContent = (cleansedContent || '').toLowerCase();
this._mentionIndex.forEach((otherId, otherName) => {
if (otherId !== rootEntityId && otherName.length >= 5) {
if (otherId !== rootEntityId && otherName.length >= 5 && lowerContent.includes(otherName)) {
const esc = otherName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b${esc}\\b`, 'i');
if (re.test(cleansedContent)) {
Expand Down
2 changes: 1 addition & 1 deletion ai/graph/MarkdownASTParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ class MarkdownASTParser {
.replace(/^>\s*/gm, '') // 9. Blockquotes
.replace(/^\|.*\|$/gm, '') // 10. Complete Markdown table rows
.replace(/\|.*\|/g, '') // 10b. Table fragments
.replace(/^[a-zA-Z0-9_\s]+:\s*.*$/gm, '') // 11. Key-value metadata lines (Name: Bikash, Time: 10:04)
.replace(/^(?:tags?|names?|authors?|attendees|people|locations?|venue|place|city|time|date|datetime):\s*.*$/gmi, '') // 11. Specific metadata lines
.replace(/\[(.*?)\]\((.*?)\)/g, '$1') // 12. Standard links -> label
.replace(/\[\[(.*?)\]\]/g, (m, inner) => inner.includes('|') ? inner.split('|')[1].trim() : inner.trim()) // 13. Wikilinks
.replace(/^\s*#{1,6}\s+/gm, '') // 14. Headings
Expand Down
13 changes: 8 additions & 5 deletions ai/graph/semantic/SemanticExtractionEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,19 @@ class SemanticExtractionEngine {
? parseFloat((confidences.reduce((a, b) => a + b, 0) / confidences.length).toFixed(3))
: 0.0;

const cleanEntities = validation.sanitizedEntities || rawResult.entities;
const cleanRelations = validation.sanitizedRelations || rawResult.relations;

const finalResult = new ExtractionResult({
entities: rawResult.entities,
relations: rawResult.relations,
entities: cleanEntities,
relations: cleanRelations,
evidence: rawResult.evidence,
metadata: {
event: 'semantic_extraction_completed',
docId,
model: adapter.modelId || this.config.model,
entities: rawResult.entities.length,
relations: rawResult.relations.length,
entities: cleanEntities.length,
relations: cleanRelations.length,
evidenceCount: rawResult.evidence.length,
durationMs,
avgConfidence,
Expand All @@ -161,7 +164,7 @@ class SemanticExtractionEngine {
if (this.telemetryEvents.length > 100) {
this.telemetryEvents.shift();
}
log.info('[Telemetry]', JSON.stringify(telemetryObj));
log.debug('[Telemetry]', JSON.stringify(telemetryObj));
}

getRecentTelemetry() {
Expand Down
58 changes: 30 additions & 28 deletions ai/graph/semantic/adapters/GLiNER2RelexAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -712,33 +712,35 @@ class GLiNER2RelexAdapter extends ModelAdapter {
);

if (decodedRels.length > 0) {
const bestRel = decodedRels[0];
for (let i = 0; i < sentEnts.length; i++) {
for (let j = 0; j < sentEnts.length; j++) {
if (i === j) continue;
const e1 = sentEnts[i];
const e2 = sentEnts[j];

const ev = new Evidence({
sourceFile: docId || metadata.sourceFile || 'doc',
lineNumber: sentIdx + 1,
paragraphId: `p-${sentIdx + 1}`,
rawSnippet: sent.text,
extractionModel: 'gliner2-relex',
timestamp: new Date().toISOString(),
confidence: bestRel.confidence
});
rawEvidenceList.push(ev);

extractedRelations.push(new Relationship({
sourceEntityId: e1.id,
targetEntityId: e2.id,
relationType: bestRel.type,
confidence: bestRel.confidence,
sourceEvidence: ev,
sourceText: e1.text,
targetText: e2.text
}));
for (const rel of decodedRels) {
// Match relation to the most appropriate entity pair in the sentence
for (let i = 0; i < sentEnts.length; i++) {
for (let j = i + 1; j < sentEnts.length; j++) {
const e1 = sentEnts[i];
const e2 = sentEnts[j];

// Create directed relation from e1 to e2 once per matching pair
const ev = new Evidence({
sourceFile: docId || metadata.sourceFile || 'doc',
lineNumber: sentIdx + 1,
paragraphId: `p-${sentIdx + 1}`,
rawSnippet: sent.text,
extractionModel: 'gliner2-relex',
timestamp: new Date().toISOString(),
confidence: rel.confidence
});
rawEvidenceList.push(ev);

extractedRelations.push(new Relationship({
sourceEntityId: e1.id,
targetEntityId: e2.id,
relationType: rel.type,
confidence: rel.confidence,
sourceEvidence: ev,
sourceText: e1.text,
targetText: e2.text
}));
}
}
}
}
Expand All @@ -752,7 +754,7 @@ class GLiNER2RelexAdapter extends ModelAdapter {
}
}

if (extractedRelations.length === 0 && extractedEntities.length >= 2 && targetRelationTypes.length > 0) {
if (this.isMockMode && extractedRelations.length === 0 && extractedEntities.length >= 2 && targetRelationTypes.length > 0) {
this._mockGenerateRelations(extractedEntities, targetRelationTypes, content, docId, metadata, rawEvidenceList, extractedRelations);
}

Expand Down
Loading
Loading