persyst-mcp 2.2.4 → 2.2.6
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/README.md +64 -2
- package/bin/export.js +116 -0
- package/bin/import.js +160 -0
- package/bin/init.js +168 -32
- package/bin/mcp.js +7 -0
- package/hooks/persyst-hook.js +9 -10
- package/index.js +42 -12
- package/package.json +15 -10
- package/src/attestation.js +49 -28
- package/src/database.js +229 -36
- package/src/events.js +19 -0
- package/src/extractor-heuristic.js +505 -324
- package/src/sdk.d.ts +175 -0
- package/src/sdk.js +218 -0
- package/src/search.js +144 -83
- package/src/server.js +766 -93
- package/src/setup-wasm.js +34 -39
- package/src/text-utils.js +41 -0
- package/src/tools.js +58 -46
- package/src/watcher.js +174 -50
package/src/setup-wasm.js
CHANGED
|
@@ -9,62 +9,57 @@ const require = createRequire(import.meta.url);
|
|
|
9
9
|
const onnxWebPath = require.resolve('onnxruntime-web');
|
|
10
10
|
const wasmDir = path.dirname(onnxWebPath);
|
|
11
11
|
|
|
12
|
-
// Redirect native Node session creation to WebAssembly session creation
|
|
13
12
|
ONNX_NODE.InferenceSession.create = ONNX_WEB.InferenceSession.create;
|
|
14
13
|
|
|
15
|
-
// Override URL.createObjectURL to return file URL of the existing local file
|
|
16
14
|
const originalCreateObjectURL = URL.createObjectURL;
|
|
17
|
-
|
|
15
|
+
const patchedCreateObjectURL = (blob) => {
|
|
18
16
|
const type = blob.type || '';
|
|
19
17
|
if (type.includes('javascript') || type.includes('mjs')) {
|
|
20
18
|
const filePath = path.join(wasmDir, 'ort-wasm-simd-threaded.asyncify.mjs');
|
|
21
|
-
|
|
22
|
-
return fileUrl;
|
|
19
|
+
return pathToFileURL(filePath).href;
|
|
23
20
|
}
|
|
24
21
|
return originalCreateObjectURL(blob);
|
|
25
22
|
};
|
|
23
|
+
URL.createObjectURL = patchedCreateObjectURL;
|
|
24
|
+
|
|
25
|
+
function readLocalFile(filePath, urlStr) {
|
|
26
|
+
const normalized = path.normalize(filePath);
|
|
27
|
+
const buffer = fs.readFileSync(normalized);
|
|
28
|
+
let contentType = 'application/octet-stream';
|
|
29
|
+
if (normalized.endsWith('.wasm')) contentType = 'application/wasm';
|
|
30
|
+
else if (normalized.endsWith('.mjs') || normalized.endsWith('.js')) contentType = 'text/javascript';
|
|
31
|
+
else if (normalized.endsWith('.onnx') || normalized.endsWith('.ort')) contentType = 'application/octet-stream';
|
|
32
|
+
return new Response(buffer, {
|
|
33
|
+
status: 200,
|
|
34
|
+
statusText: 'OK',
|
|
35
|
+
headers: { 'Content-Type': contentType }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
26
38
|
|
|
27
|
-
// Override global fetch to load ONNX WASM binaries and model files from local disk
|
|
28
39
|
const originalFetch = globalThis.fetch;
|
|
29
|
-
|
|
40
|
+
const patchedFetch = async (url, options) => {
|
|
30
41
|
const urlStr = typeof url === 'string' ? url : url.url;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
let filePath = '';
|
|
34
|
-
|
|
35
|
-
if (urlStr.startsWith('file://')) {
|
|
36
|
-
isLocal = true;
|
|
37
|
-
filePath = fileURLToPath(urlStr);
|
|
38
|
-
} else if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://') && !urlStr.startsWith('data:')) {
|
|
39
|
-
isLocal = true;
|
|
40
|
-
filePath = urlStr;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Intercept onnxruntime-web CDN URLs and route them to node_modules/onnxruntime-web/dist
|
|
42
|
+
|
|
43
|
+
// onnxruntime-web WASM binaries — resolve from node_modules
|
|
44
44
|
if (urlStr.includes('onnxruntime-web') || urlStr.includes('ort-wasm')) {
|
|
45
|
-
isLocal = true;
|
|
46
45
|
const filename = urlStr.split('/').pop().split('?')[0].split('#')[0];
|
|
47
|
-
|
|
46
|
+
return readLocalFile(path.join(wasmDir, filename), urlStr);
|
|
48
47
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
|
|
49
|
+
// file:// URLs — Node.js fetch does not support them natively
|
|
50
|
+
if (urlStr.startsWith('file://')) {
|
|
51
|
+
return readLocalFile(fileURLToPath(urlStr), urlStr);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// fallback for any non-http/https/data URL (onnxruntime internal schemes, bare paths)
|
|
55
|
+
if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://') && !urlStr.startsWith('data:')) {
|
|
52
56
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
else if (filePath.endsWith('.mjs') || filePath.endsWith('.js')) contentType = 'text/javascript';
|
|
57
|
-
|
|
58
|
-
return new Response(buffer, {
|
|
59
|
-
status: 200,
|
|
60
|
-
statusText: 'OK',
|
|
61
|
-
headers: { 'Content-Type': contentType }
|
|
62
|
-
});
|
|
63
|
-
} catch (err) {
|
|
64
|
-
console.error('[persyst] Failed to read local file:', filePath, err.message);
|
|
65
|
-
throw err;
|
|
57
|
+
return readLocalFile(urlStr, urlStr);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw e;
|
|
66
60
|
}
|
|
67
61
|
}
|
|
68
|
-
|
|
62
|
+
|
|
69
63
|
return originalFetch(url, options);
|
|
70
64
|
};
|
|
65
|
+
globalThis.fetch = patchedFetch;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* text-utils.js — Shared text-processing helpers used across Persyst.
|
|
3
|
+
*
|
|
4
|
+
* Keeping these in one place avoids duplicated logic and divergent behavior
|
|
5
|
+
* between modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compute Jaccard similarity between two text strings.
|
|
10
|
+
* Uses word-level tokenization for efficiency.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} a - First text
|
|
13
|
+
* @param {string} b - Second text
|
|
14
|
+
* @returns {number} Similarity score between 0 and 1
|
|
15
|
+
*/
|
|
16
|
+
export function jaccardSimilarity(a, b) {
|
|
17
|
+
if (!a || !b) return 0;
|
|
18
|
+
|
|
19
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/));
|
|
20
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/));
|
|
21
|
+
|
|
22
|
+
let intersection = 0;
|
|
23
|
+
for (const word of wordsA) {
|
|
24
|
+
if (wordsB.has(word)) intersection++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
28
|
+
return union === 0 ? 0 : intersection / union;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute Jaccard distance between two text strings.
|
|
33
|
+
* Distance = 1 - similarity, so 0 means identical and 1 means completely different.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} a - First text
|
|
36
|
+
* @param {string} b - Second text
|
|
37
|
+
* @returns {number} Distance score between 0 and 1
|
|
38
|
+
*/
|
|
39
|
+
export function jaccardDistance(a, b) {
|
|
40
|
+
return 1 - jaccardSimilarity(a, b);
|
|
41
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { generateEmbedding } from './embeddings.js';
|
|
16
16
|
import db, {
|
|
17
|
+
stmts,
|
|
17
18
|
insertMemory,
|
|
18
19
|
insertVector,
|
|
20
|
+
redactSecrets,
|
|
19
21
|
getMemory,
|
|
20
22
|
updateMemoryContent,
|
|
21
23
|
deleteMemory,
|
|
@@ -45,9 +47,11 @@ import db, {
|
|
|
45
47
|
getNamespaceStats
|
|
46
48
|
} from './database.js';
|
|
47
49
|
import { searchHybrid, getOptimizedContext, consolidateMemories } from './search.js';
|
|
50
|
+
import { jaccardDistance } from './text-utils.js';
|
|
48
51
|
import { getRecentCommits } from './git.js';
|
|
49
52
|
import { verifyChainIntegrity } from './attestation.js';
|
|
50
53
|
import { searchCache } from './cache.js';
|
|
54
|
+
import { memoryEventBus } from './events.js';
|
|
51
55
|
|
|
52
56
|
// ============================================================
|
|
53
57
|
// CONSTANTS
|
|
@@ -108,8 +112,11 @@ export async function addMemoryInternal({ content, importance = 1.0, agent_id, s
|
|
|
108
112
|
try {
|
|
109
113
|
const normalizedAgentId = agent_id ? agent_id.toLowerCase() : null;
|
|
110
114
|
|
|
115
|
+
// Redact secrets/credentials on write
|
|
116
|
+
const redactedContent = redactSecrets(content);
|
|
117
|
+
|
|
111
118
|
// Bug 7 + Feature 4: Validate content size
|
|
112
|
-
const validation = validateMemoryContent(
|
|
119
|
+
const validation = validateMemoryContent(redactedContent);
|
|
113
120
|
if (!validation.valid) {
|
|
114
121
|
return { error: validation.error };
|
|
115
122
|
}
|
|
@@ -118,14 +125,13 @@ export async function addMemoryInternal({ content, importance = 1.0, agent_id, s
|
|
|
118
125
|
const namespace = (shared || !normalizedAgentId) ? 'shared' : normalizedAgentId;
|
|
119
126
|
|
|
120
127
|
// Deduplication check (namespace-aware)
|
|
121
|
-
const existing = getMemoryByContent(
|
|
128
|
+
const existing = getMemoryByContent(redactedContent, namespace);
|
|
122
129
|
if (existing) {
|
|
123
130
|
// Re-attribute provenance to the calling agent if it was previously auto-attributed to log-watcher
|
|
124
131
|
const prov = getProvenance(existing.id);
|
|
125
132
|
if (prov && (prov.source_id === 'antigravity-worker' || prov.source_id === 'user-dialogue') && normalizedAgentId) {
|
|
126
133
|
try {
|
|
127
|
-
|
|
128
|
-
.run(normalizedAgentId, existing.id);
|
|
134
|
+
stmts.updateProvenanceOwner.run(normalizedAgentId, existing.id);
|
|
129
135
|
incrementAgentStat(normalizedAgentId, 'created');
|
|
130
136
|
} catch (e) {
|
|
131
137
|
console.error(`[persyst] Re-attribute provenance error: ${e.message}`);
|
|
@@ -140,18 +146,21 @@ export async function addMemoryInternal({ content, importance = 1.0, agent_id, s
|
|
|
140
146
|
};
|
|
141
147
|
}
|
|
142
148
|
|
|
143
|
-
const id = insertMemory(
|
|
149
|
+
const id = insertMemory(redactedContent, importance, {
|
|
144
150
|
source_type: normalizedAgentId ? 'agent' : 'manual',
|
|
145
151
|
source_id: normalizedAgentId,
|
|
146
152
|
confidence: 1.0
|
|
147
153
|
}, namespace);
|
|
148
154
|
|
|
149
|
-
const embedding = await generateEmbedding(
|
|
155
|
+
const embedding = await generateEmbedding(redactedContent);
|
|
150
156
|
insertVector(id, embedding);
|
|
151
157
|
|
|
152
158
|
// Feature 1: Invalidate search cache on write
|
|
153
159
|
searchCache.invalidate();
|
|
154
160
|
|
|
161
|
+
// Broadcast to SSE subscribers (HTTP gateway + SSE clients)
|
|
162
|
+
memoryEventBus.emit('memory_added', { id, content: redactedContent, namespace, source: normalizedAgentId || 'manual' });
|
|
163
|
+
|
|
155
164
|
// Feature 2: Contradiction Detection
|
|
156
165
|
let contradictions = [];
|
|
157
166
|
try {
|
|
@@ -165,20 +174,20 @@ export async function addMemoryInternal({ content, importance = 1.0, agent_id, s
|
|
|
165
174
|
const existingMemory = getMemoryById(hitId, namespace);
|
|
166
175
|
if (!existingMemory) continue;
|
|
167
176
|
|
|
168
|
-
const jaccard = jaccardDistance(
|
|
177
|
+
const jaccard = jaccardDistance(redactedContent, existingMemory.content);
|
|
169
178
|
// Contradiction: similar topic (high similarity), but differing key terms
|
|
170
179
|
if (jaccard > 0 && jaccard < 0.65) {
|
|
171
180
|
// Fetch provenances for trust calculation
|
|
172
181
|
const oldProv = getProvenance(hitId);
|
|
173
182
|
let oldReputation = 1.0;
|
|
174
183
|
if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
|
|
175
|
-
const agentRow =
|
|
184
|
+
const agentRow = stmts.getReputationScore.get(oldProv.source_id);
|
|
176
185
|
if (agentRow) oldReputation = agentRow.reputation_score;
|
|
177
186
|
}
|
|
178
187
|
|
|
179
188
|
let newReputation = 1.0;
|
|
180
189
|
if (normalizedAgentId) {
|
|
181
|
-
const agentRow =
|
|
190
|
+
const agentRow = stmts.getReputationScore.get(normalizedAgentId);
|
|
182
191
|
if (agentRow) newReputation = agentRow.reputation_score;
|
|
183
192
|
}
|
|
184
193
|
|
|
@@ -187,7 +196,11 @@ export async function addMemoryInternal({ content, importance = 1.0, agent_id, s
|
|
|
187
196
|
|
|
188
197
|
const isSelfUpdate = oldProv && oldProv.source_type === 'agent' && oldProv.source_id === normalizedAgentId;
|
|
189
198
|
|
|
190
|
-
if (isSelfUpdate
|
|
199
|
+
if (isSelfUpdate) {
|
|
200
|
+
continue; // Same agent: treat as complementary, not contradictory
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (trustNew > trustOld) {
|
|
191
204
|
// New is preferred
|
|
192
205
|
logContradiction(hitId, id, `Auto-detected contradiction: new memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
|
|
193
206
|
contradictions.push({
|
|
@@ -316,11 +329,13 @@ export function registerTools(server) {
|
|
|
316
329
|
'get_memory',
|
|
317
330
|
'Get a specific memory by its ID. Boosts its importance automatically.',
|
|
318
331
|
{
|
|
319
|
-
id: z.number().describe('Memory ID to retrieve')
|
|
332
|
+
id: z.number().describe('Memory ID to retrieve'),
|
|
333
|
+
agent_id: z.string().optional().describe('Agent ID — restricts access to this agent\'s namespace + shared')
|
|
320
334
|
},
|
|
321
|
-
async ({ id }) => {
|
|
335
|
+
async ({ id, agent_id }) => {
|
|
322
336
|
try {
|
|
323
|
-
const
|
|
337
|
+
const namespace = agent_id ? agent_id.toLowerCase() : null;
|
|
338
|
+
const memory = getMemory(id, namespace);
|
|
324
339
|
if (!memory) return text({ error: `Memory #${id} not found` });
|
|
325
340
|
return text(memory);
|
|
326
341
|
} catch (err) {
|
|
@@ -342,13 +357,17 @@ export function registerTools(server) {
|
|
|
342
357
|
try {
|
|
343
358
|
const normalizedAgentId = agent_id ? agent_id.toLowerCase() : null;
|
|
344
359
|
|
|
360
|
+
// Redact secrets/credentials on update
|
|
361
|
+
const redactedContent = redactSecrets(content);
|
|
362
|
+
|
|
345
363
|
// Bug 7 + Feature 4: Validate content size
|
|
346
|
-
const validation = validateMemoryContent(
|
|
364
|
+
const validation = validateMemoryContent(redactedContent);
|
|
347
365
|
if (!validation.valid) {
|
|
348
366
|
return text({ error: validation.error });
|
|
349
367
|
}
|
|
350
368
|
|
|
351
|
-
const
|
|
369
|
+
const namespace = normalizedAgentId;
|
|
370
|
+
const oldMemory = getMemory(id, namespace);
|
|
352
371
|
if (!oldMemory) return text({ error: `Memory #${id} not found` });
|
|
353
372
|
|
|
354
373
|
// Retrieve old agent_id from provenance
|
|
@@ -357,7 +376,7 @@ export function registerTools(server) {
|
|
|
357
376
|
|
|
358
377
|
// Insert new version
|
|
359
378
|
const newId = insertMemory(
|
|
360
|
-
|
|
379
|
+
redactedContent,
|
|
361
380
|
oldMemory.importance_score,
|
|
362
381
|
{
|
|
363
382
|
source_type: resolvedAgentId ? 'agent' : 'manual',
|
|
@@ -368,7 +387,7 @@ export function registerTools(server) {
|
|
|
368
387
|
id
|
|
369
388
|
);
|
|
370
389
|
|
|
371
|
-
const embedding = await generateEmbedding(
|
|
390
|
+
const embedding = await generateEmbedding(redactedContent);
|
|
372
391
|
insertVector(newId, embedding);
|
|
373
392
|
|
|
374
393
|
// Record contradiction and archive the old one
|
|
@@ -377,6 +396,9 @@ export function registerTools(server) {
|
|
|
377
396
|
// Feature 1: Invalidate search cache on write
|
|
378
397
|
searchCache.invalidate();
|
|
379
398
|
|
|
399
|
+
// Broadcast update to SSE subscribers
|
|
400
|
+
memoryEventBus.emit('memory_updated', { old_id: id, new_id: newId, namespace: oldMemory.namespace || 'shared' });
|
|
401
|
+
|
|
380
402
|
return text({
|
|
381
403
|
success: true,
|
|
382
404
|
id: newId,
|
|
@@ -393,16 +415,24 @@ export function registerTools(server) {
|
|
|
393
415
|
'delete_memory',
|
|
394
416
|
'Permanently delete a memory by its ID.',
|
|
395
417
|
{
|
|
396
|
-
id: z.number().describe('Memory ID to delete')
|
|
418
|
+
id: z.number().describe('Memory ID to delete'),
|
|
419
|
+
agent_id: z.string().optional().describe('Agent ID — restricts deletion to this agent\'s namespace + shared')
|
|
397
420
|
},
|
|
398
|
-
async ({ id }) => {
|
|
421
|
+
async ({ id, agent_id }) => {
|
|
399
422
|
try {
|
|
423
|
+
const namespace = agent_id ? agent_id.toLowerCase() : null;
|
|
424
|
+
const memory = getMemory(id, namespace);
|
|
425
|
+
if (!memory) return text({ error: `Memory #${id} not found` });
|
|
426
|
+
|
|
400
427
|
const deleted = deleteMemory(id);
|
|
401
428
|
if (!deleted) return text({ error: `Memory #${id} not found` });
|
|
402
429
|
|
|
403
430
|
// Feature 1: Invalidate search cache on write
|
|
404
431
|
searchCache.invalidate();
|
|
405
432
|
|
|
433
|
+
// Broadcast deletion to SSE subscribers
|
|
434
|
+
memoryEventBus.emit('memory_deleted', { id, namespace: memory.namespace || 'shared' });
|
|
435
|
+
|
|
406
436
|
return text({ success: true, id, message: `Memory #${id} deleted` });
|
|
407
437
|
} catch (err) {
|
|
408
438
|
return text({ error: err.message });
|
|
@@ -538,14 +568,16 @@ export function registerTools(server) {
|
|
|
538
568
|
{
|
|
539
569
|
entity_name: z.string().describe('Name of the entity'),
|
|
540
570
|
memory_id: z.number().describe('ID of the memory to link'),
|
|
541
|
-
relation: z.string().default('mentions').describe('Relationship type')
|
|
571
|
+
relation: z.string().default('mentions').describe('Relationship type'),
|
|
572
|
+
agent_id: z.string().optional().describe('Agent ID — restricts linking to this agent\'s namespace + shared')
|
|
542
573
|
},
|
|
543
|
-
async ({ entity_name, memory_id, relation }) => {
|
|
574
|
+
async ({ entity_name, memory_id, relation, agent_id }) => {
|
|
544
575
|
try {
|
|
576
|
+
const namespace = agent_id ? agent_id.toLowerCase() : null;
|
|
545
577
|
const entity = getEntityByName(entity_name);
|
|
546
578
|
if (!entity) return text({ error: `Entity "${entity_name}" not found.` });
|
|
547
579
|
|
|
548
|
-
const memory = getMemory(memory_id);
|
|
580
|
+
const memory = getMemory(memory_id, namespace);
|
|
549
581
|
if (!memory) return text({ error: `Memory #${memory_id} not found` });
|
|
550
582
|
|
|
551
583
|
insertEdge(entity.id, memory_id, relation, 'entity', 'memory');
|
|
@@ -778,12 +810,13 @@ export function registerTools(server) {
|
|
|
778
810
|
query: z.string().describe('The search query context'),
|
|
779
811
|
max_tokens: z.number().default(4000).describe('Token budget for LLM context compression (default: 4000)'),
|
|
780
812
|
agent_id: z.string().optional().describe('Agent ID requesting context — filters to this agent\'s namespace + shared'),
|
|
781
|
-
session_id: z.string().optional().describe('Session ID')
|
|
813
|
+
session_id: z.string().optional().describe('Session ID'),
|
|
814
|
+
intent: z.string().optional().describe('The active task intent / category (e.g. debugging, ui_styling, database_management)')
|
|
782
815
|
},
|
|
783
|
-
async ({ query, max_tokens, agent_id, session_id }) => {
|
|
816
|
+
async ({ query, max_tokens, agent_id, session_id, intent }) => {
|
|
784
817
|
try {
|
|
785
818
|
const namespace = agent_id || null;
|
|
786
|
-
const contextData = await getOptimizedContext(query, max_tokens, agent_id, session_id, namespace);
|
|
819
|
+
const contextData = await getOptimizedContext(query, max_tokens, agent_id, session_id, namespace, intent);
|
|
787
820
|
return text(contextData);
|
|
788
821
|
} catch (err) {
|
|
789
822
|
return text({ error: err.message });
|
|
@@ -822,27 +855,6 @@ function text(data) {
|
|
|
822
855
|
};
|
|
823
856
|
}
|
|
824
857
|
|
|
825
|
-
/**
|
|
826
|
-
* Compute Jaccard distance between two text strings.
|
|
827
|
-
* Used for contradiction detection — higher distance means more different content.
|
|
828
|
-
* @param {string} a - First text
|
|
829
|
-
* @param {string} b - Second text
|
|
830
|
-
* @returns {number} Distance score between 0 (identical) and 1 (completely different)
|
|
831
|
-
*/
|
|
832
|
-
function jaccardDistance(a, b) {
|
|
833
|
-
const wordsA = new Set(a.toLowerCase().split(/\s+/));
|
|
834
|
-
const wordsB = new Set(b.toLowerCase().split(/\s+/));
|
|
835
|
-
|
|
836
|
-
let intersection = 0;
|
|
837
|
-
for (const word of wordsA) {
|
|
838
|
-
if (wordsB.has(word)) intersection++;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const union = wordsA.size + wordsB.size - intersection;
|
|
842
|
-
if (union === 0) return 0;
|
|
843
|
-
return 1 - (intersection / union);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
858
|
/**
|
|
847
859
|
* Compute word-level diff between two text strings using dynamic programming.
|
|
848
860
|
* Highlights additions as [+added+] and deletions as [-deleted-].
|