@cachly-dev/mcp-server 0.9.22 → 0.9.24
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/dist/__tests__/brain-flow.test.js +230 -0
- package/dist/__tests__/brain-flow.test.js.map +1 -1
- package/dist/__tests__/confidence.test.d.ts +13 -0
- package/dist/__tests__/confidence.test.js +233 -0
- package/dist/__tests__/confidence.test.js.map +1 -0
- package/dist/__tests__/handlers-unit.test.d.ts +12 -0
- package/dist/__tests__/handlers-unit.test.js +505 -0
- package/dist/__tests__/handlers-unit.test.js.map +1 -0
- package/dist/search.test.js +202 -0
- package/dist/search.test.js.map +1 -1
- package/dist/tools.js +18 -9
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/brain-flow.test.ts +266 -0
- package/src/__tests__/confidence.test.ts +285 -0
- package/src/__tests__/handlers-unit.test.ts +677 -0
- package/src/search.test.ts +239 -0
- package/src/tools.ts +17 -8
package/src/search.test.ts
CHANGED
|
@@ -606,4 +606,243 @@ describe('keywordSearch prefix-matching', () => {
|
|
|
606
606
|
});
|
|
607
607
|
});
|
|
608
608
|
|
|
609
|
+
// ── tokenize edge cases ───────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
describe('tokenize — edge cases', () => {
|
|
612
|
+
it('handles pure numbers', () => {
|
|
613
|
+
const tokens = tokenize('404 500 200');
|
|
614
|
+
// Numbers are preserved as tokens if length > 1
|
|
615
|
+
expect(tokens.some(t => /\d/.test(t))).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('handles URLs — strips protocol and splits on slashes', () => {
|
|
619
|
+
const tokens = tokenize('https://api.cachly.dev/v1/instances');
|
|
620
|
+
expect(tokens.some(t => t.includes('cachly') || t.includes('api') || t.includes('instances'))).toBe(true);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('handles markdown formatting — strips asterisks and backticks', () => {
|
|
624
|
+
const tokens = tokenize('**Deploy** the `server` now');
|
|
625
|
+
expect(tokens).toContain('deploy');
|
|
626
|
+
expect(tokens).toContain('server');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('deduplicates identical tokens (from cross-lingual expansion)', () => {
|
|
630
|
+
const tokens = tokenize('deploy deploy');
|
|
631
|
+
const counts: Record<string, number> = {};
|
|
632
|
+
for (const t of tokens) counts[t] = (counts[t] ?? 0) + 1;
|
|
633
|
+
// Exact duplicates are deduplicated
|
|
634
|
+
expect(counts['deploy'] ?? 0).toBeLessThanOrEqual(2);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('handles very long single words (hashed to bigram)', () => {
|
|
638
|
+
const longWord = 'a'.repeat(50);
|
|
639
|
+
const tokens = tokenize(longWord);
|
|
640
|
+
// Should not crash and should produce at least one token
|
|
641
|
+
expect(tokens.length).toBeGreaterThanOrEqual(0);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('handles emoji in text gracefully', () => {
|
|
645
|
+
const tokens = tokenize('🚀 deploy 🔥 server');
|
|
646
|
+
expect(tokens).toContain('deploy');
|
|
647
|
+
expect(tokens).toContain('server');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('handles mixed script in single sentence', () => {
|
|
651
|
+
const tokens = tokenize('deploy サーバー to production 서버');
|
|
652
|
+
expect(tokens).toContain('deploy');
|
|
653
|
+
expect(tokens.some(t => t === 'server' || t.includes('sa') || t.includes('ba'))).toBe(true);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ── levenshtein edge cases ────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
describe('levenshtein — additional cases', () => {
|
|
660
|
+
it('single character strings', () => {
|
|
661
|
+
expect(levenshtein('a', 'a')).toBe(0);
|
|
662
|
+
expect(levenshtein('a', 'b')).toBe(1);
|
|
663
|
+
expect(levenshtein('a', 'ab')).toBe(1);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('symmetric property: d(a,b) === d(b,a)', () => {
|
|
667
|
+
const pairs: [string, string][] = [
|
|
668
|
+
['deploy', 'deploi'],
|
|
669
|
+
['hello', 'world'],
|
|
670
|
+
['abc', ''],
|
|
671
|
+
['redis', 'reds'],
|
|
672
|
+
];
|
|
673
|
+
for (const [a, b] of pairs) {
|
|
674
|
+
expect(levenshtein(a, b)).toBe(levenshtein(b, a));
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('triangle inequality: d(a,c) ≤ d(a,b) + d(b,c)', () => {
|
|
679
|
+
const a = 'docker';
|
|
680
|
+
const b = 'doker'; // 1 edit from docker
|
|
681
|
+
const c = 'dcker'; // 1 edit from docker, 2 from doker
|
|
682
|
+
expect(levenshtein(a, c)).toBeLessThanOrEqual(levenshtein(a, b) + levenshtein(b, c));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('common tech term typos are within distance 2', () => {
|
|
686
|
+
const typos: [string, string][] = [
|
|
687
|
+
['docker', 'docekr'],
|
|
688
|
+
['redis', 'redsi'],
|
|
689
|
+
['nginx', 'nigx'],
|
|
690
|
+
['postgres', 'posgres'],
|
|
691
|
+
];
|
|
692
|
+
for (const [correct, typo] of typos) {
|
|
693
|
+
expect(levenshtein(correct, typo)).toBeLessThanOrEqual(2);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// ── recencyBoost edge cases ───────────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
describe('recencyBoost — additional cases', () => {
|
|
701
|
+
it('returns 1.0 for null/undefined', () => {
|
|
702
|
+
expect(recencyBoost(undefined)).toBe(1.0);
|
|
703
|
+
expect(recencyBoost(null as unknown as undefined)).toBe(1.0);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('returns max boost (1.5) for timestamps in the future', () => {
|
|
707
|
+
const future = Date.now() + 60_000; // 1 minute ahead
|
|
708
|
+
const boost = recencyBoost(future);
|
|
709
|
+
expect(boost).toBeCloseTo(1.5, 1);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('boost is consistent for same age', () => {
|
|
713
|
+
const ts = Date.now() - 2 * 86400000;
|
|
714
|
+
expect(recencyBoost(ts)).toBeCloseTo(recencyBoost(ts), 5);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('boost values bracket correctly for known ages', () => {
|
|
718
|
+
const now = Date.now();
|
|
719
|
+
const fresh = recencyBoost(now); // 0d
|
|
720
|
+
const oneDay = recencyBoost(now - 86400000); // 1d
|
|
721
|
+
const fourDay = recencyBoost(now - 4 * 86400000); // 4d
|
|
722
|
+
const weekOld = recencyBoost(now - 7 * 86400000); // 7d
|
|
723
|
+
const monthOld = recencyBoost(now - 30 * 86400000); // 30d
|
|
724
|
+
const yearOld = recencyBoost(now - 365 * 86400000); // 1y
|
|
725
|
+
|
|
726
|
+
expect(fresh).toBeGreaterThan(1.4);
|
|
727
|
+
expect(oneDay).toBeGreaterThan(1.2);
|
|
728
|
+
expect(fourDay).toBeGreaterThan(1.0);
|
|
729
|
+
expect(weekOld).toBeCloseTo(1.0, 1);
|
|
730
|
+
expect(monthOld).toBeLessThan(0.9);
|
|
731
|
+
expect(yearOld).toBeGreaterThanOrEqual(0.5);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// ── extractTimestamp edge cases ───────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
describe('extractTimestamp — additional cases', () => {
|
|
738
|
+
it('handles updated_at field', () => {
|
|
739
|
+
const content = '{"updated_at":"2026-05-01T10:00:00Z","key":"value"}';
|
|
740
|
+
const ts = extractTimestamp(content);
|
|
741
|
+
// Should find a valid date (updated_at or fall through to ts)
|
|
742
|
+
if (ts) {
|
|
743
|
+
expect(new Date(ts).getFullYear()).toBeGreaterThanOrEqual(2026);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('extracts from nested JSON objects', () => {
|
|
748
|
+
const content = JSON.stringify({ meta: { ts: '2026-03-15T08:00:00Z' }, value: 'test' });
|
|
749
|
+
// ts is at top-level via regex so this might not match nested — just no crash
|
|
750
|
+
expect(() => extractTimestamp(content)).not.toThrow();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('handles array-valued ts field gracefully', () => {
|
|
754
|
+
const content = '{"ts":[1,2,3],"data":"test"}';
|
|
755
|
+
expect(() => extractTimestamp(content)).not.toThrow();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('handles partial JSON (truncated)', () => {
|
|
759
|
+
const content = '{"ts":"2026-01-01T00:00:00Z","data":"tr';
|
|
760
|
+
expect(() => extractTimestamp(content)).not.toThrow();
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('returns undefined for plain text with no JSON', () => {
|
|
764
|
+
expect(extractTimestamp('just a sentence with no dates')).toBeUndefined();
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// ── splitMultiQuery completeness ──────────────────────────────────────────────
|
|
769
|
+
|
|
770
|
+
describe('splitMultiQuery — additional splitters', () => {
|
|
771
|
+
it('splits "furthermore" (English)', () => {
|
|
772
|
+
const r = splitMultiQuery('fix timeout furthermore increase heap');
|
|
773
|
+
expect(r.length).toBe(2);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('splits "additionally" (English)', () => {
|
|
777
|
+
const r = splitMultiQuery('deploy api additionally update config');
|
|
778
|
+
expect(r.length).toBe(2);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('splits "de plus" (French)', () => {
|
|
782
|
+
const r = splitMultiQuery('déployer API de plus corriger timeout');
|
|
783
|
+
expect(r.length).toBe(2);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('splits "und außerdem" (German)', () => {
|
|
787
|
+
const r = splitMultiQuery('API deployen und außerdem Timeout erhöhen');
|
|
788
|
+
expect(r.length).toBe(2);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('handles input with only separators gracefully', () => {
|
|
792
|
+
const r = splitMultiQuery(';;;');
|
|
793
|
+
// Should not crash; may return empty or single element
|
|
794
|
+
expect(Array.isArray(r)).toBe(true);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('does not split on comma inside brackets', () => {
|
|
798
|
+
// "1 item" shouldn't produce garbage splits
|
|
799
|
+
const r = splitMultiQuery('deploy API');
|
|
800
|
+
expect(r).toEqual(['deploy API']);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('preserves query with no splitter', () => {
|
|
804
|
+
const q = 'find the best way to deploy docker containers';
|
|
805
|
+
expect(splitMultiQuery(q)).toEqual([q]);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// ── CROSS_LINGUAL_MAP completeness ────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
describe('CROSS_LINGUAL_MAP — coverage', () => {
|
|
812
|
+
const REQUIRED_EN_TERMS = [
|
|
813
|
+
'deploy', 'error', 'server', 'database', 'container',
|
|
814
|
+
'cache', 'authentication', 'timeout', 'memory', 'network',
|
|
815
|
+
];
|
|
816
|
+
|
|
817
|
+
it.each(REQUIRED_EN_TERMS)('"%s" has cross-lingual entries', (term) => {
|
|
818
|
+
const entries = CROSS_LINGUAL_MAP.get(term);
|
|
819
|
+
expect(entries, `"${term}" missing from CROSS_LINGUAL_MAP`).toBeDefined();
|
|
820
|
+
expect(entries!.length).toBeGreaterThan(0);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('all values are non-empty arrays of strings', () => {
|
|
824
|
+
for (const [key, values] of CROSS_LINGUAL_MAP.entries()) {
|
|
825
|
+
expect(Array.isArray(values), `Values for "${key}" are not an array`).toBe(true);
|
|
826
|
+
for (const v of values) {
|
|
827
|
+
expect(typeof v, `Value "${v}" for key "${key}" is not a string`).toBe('string');
|
|
828
|
+
expect(v.length, `Empty string in values for "${key}"`).toBeGreaterThan(0);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('bi-directional entries exist (reverse lookups)', () => {
|
|
834
|
+
// For each forward entry, the reverse should also exist
|
|
835
|
+
let reverseCount = 0;
|
|
836
|
+
for (const [key, values] of CROSS_LINGUAL_MAP.entries()) {
|
|
837
|
+
for (const v of values) {
|
|
838
|
+
if (CROSS_LINGUAL_MAP.has(v)) {
|
|
839
|
+
reverseCount++;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// A healthy map has substantial bidirectionality
|
|
844
|
+
expect(reverseCount).toBeGreaterThan(20);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
609
848
|
|
package/src/tools.ts
CHANGED
|
@@ -1158,9 +1158,12 @@ const TOOLS = [
|
|
|
1158
1158
|
{
|
|
1159
1159
|
name: 'invite_member',
|
|
1160
1160
|
description:
|
|
1161
|
+
'MUTATION — sends an invite email immediately and cannot be undone via MCP. ' +
|
|
1161
1162
|
'Invite a team member to a Cachly organization by email. ' +
|
|
1162
|
-
'
|
|
1163
|
-
'
|
|
1163
|
+
'Requires the caller to be an admin or owner of the organization. ' +
|
|
1164
|
+
'Valid roles: admin (manage members + instances), member (read + cache ops). Default role: member. ' +
|
|
1165
|
+
'Returns an error if the email is already a member or has a pending invite.',
|
|
1166
|
+
// NOTE: schema intentionally omits 'owner' — owners can only be set via the billing portal.
|
|
1164
1167
|
inputSchema: {
|
|
1165
1168
|
type: 'object',
|
|
1166
1169
|
properties: {
|
|
@@ -1474,9 +1477,12 @@ const TOOLS = [
|
|
|
1474
1477
|
{
|
|
1475
1478
|
name: 'brain_predict',
|
|
1476
1479
|
description:
|
|
1477
|
-
'
|
|
1478
|
-
'
|
|
1479
|
-
'
|
|
1480
|
+
'READ-ONLY — no side effects, no writes, no external network calls. ' +
|
|
1481
|
+
'Predictive Pre-fetch Engine (PPE): given your current context, reads the CKG in your Redis instance ' +
|
|
1482
|
+
'to predict likely failures and return the highest-confidence fixes. ' +
|
|
1483
|
+
'"Pre-load" means results are returned inline — nothing is cached or persisted. ' +
|
|
1484
|
+
'Requires a valid instance_id (your Redis brain). No rate limits. ' +
|
|
1485
|
+
'Call at session start when working on a specific feature or debugging area.',
|
|
1480
1486
|
inputSchema: {
|
|
1481
1487
|
type: 'object',
|
|
1482
1488
|
properties: {
|
|
@@ -1537,9 +1543,12 @@ const TOOLS = [
|
|
|
1537
1543
|
{
|
|
1538
1544
|
name: 'cls_install_hooks',
|
|
1539
1545
|
description:
|
|
1540
|
-
'
|
|
1541
|
-
'
|
|
1542
|
-
'
|
|
1546
|
+
'READ-ONLY — outputs text only, writes no files, makes no network calls, has no side effects. ' +
|
|
1547
|
+
'Generates ready-to-paste shell scripts: a git post-commit hook and/or a GitHub Actions step. ' +
|
|
1548
|
+
'You must manually copy and install the output. ' +
|
|
1549
|
+
'Once the generated scripts are installed, each git commit or CI run will make outbound HTTPS calls ' +
|
|
1550
|
+
'to api.cachly.dev to feed learning signals to your brain. ' +
|
|
1551
|
+
'No auth required to call this tool — only an instance_id. Run once per repository.',
|
|
1543
1552
|
inputSchema: {
|
|
1544
1553
|
type: 'object',
|
|
1545
1554
|
properties: {
|