diff --git a/.contextplus/hubs/tools.md b/.contextplus/hubs/tools.md new file mode 100644 index 0000000..9e51d91 --- /dev/null +++ b/.contextplus/hubs/tools.md @@ -0,0 +1 @@ +IyBDb250ZXh0KyBUb29scyBHcmFwaAoKVGhlIGNvbXBsZXRlIHNlbWFudGljIGdyYXBoIG9mIGFsbCBDb250ZXh0KyBNQ1AgdG9vbHMgYW5kIHRoZWlyIHJlbGF0aW9uc2hpcHMg4oCUIG1hcHBpbmcgZXZlcnkgdG9vbCB0byBpdHMgc291cmNlIGZpbGUsIGNvcmUgZGVwZW5kZW5jeSwgYW5kIHdvcmtmbG93IHBvc2l0aW9uLgoKLS0tCgojIyBEaXNjb3ZlcnkgTGF5ZXIKClRoZXNlIHRvb2xzIG1hcCB0aGUgc3RydWN0dXJlIGFuZCBtZWFuaW5nIG9mIGEgY29kZWJhc2UuCgpbW2dldF9jb250ZXh0X3RyZWVdXShzcmMvdG9vbHMvY29udGV4dC10cmVlLnRzKSAtIFN0cnVjdHVyYWwgQVNUIHRyZWUgd2l0aCBmaWxlIGhlYWRlcnMgYW5kIHN5bWJvbCBsaW5lIHJhbmdlcy4gVGhlIHJvb3QgZW50cnkgcG9pbnQ6IHN0YXJ0IGV2ZXJ5IHRhc2sgaGVyZS4KCi0gcmVsaWVzIG9uIOKGkiBbW3BhcnNlcl1dIFtbd2Fsa2VyXV0KLSBmZWVkcyBpbnRvIOKGkiBbW3NlbWFudGljX2NvZGVfc2VhcmNoXV0gW1tnZXRfZmlsZV9za2VsZXRvbl1dCi0gcmVuYW1lIHRhcmdldDogYHRyZWVgIChUT0RPIHYxKQoKW1tnZXRfZmlsZV9za2VsZXRvbl1dKHNyYy90b29scy9maWxlLXNrZWxldG9uLnRzKSAtIEZ1bmN0aW9uIHNpZ25hdHVyZXMgYW5kIHR5cGUgZGVmaW5pdGlvbnMgd2l0aG91dCBib2RpZXMuIFVzZSBiZWZvcmUgYSBmdWxsIGZpbGUgcmVhZC4KCi0gcmVsaWVzIG9uIOKGkiBbW3BhcnNlcl1dCi0gZmVlZHMgaW50byDihpIgW1tnZXRfYmxhc3RfcmFkaXVzXV0gW1twcm9wb3NlX2NvbW1pdF1dCi0gcmVuYW1lIHRhcmdldDogYHNrZWxldG9uYCAoVE9ETyB2MSkKCltbc2VtYW50aWNfY29kZV9zZWFyY2hdXShzcmMvdG9vbHMvc2VtYW50aWMtc2VhcmNoLnRzKSAtIEVtYmVkZGluZy1iYXNlZCBzZWFyY2ggYnkgbWVhbmluZyBvdmVyIGZpbGUgaGVhZGVycyBhbmQgc3ltYm9scy4KCi0gcmVsaWVzIG9uIOKGkiBbW2VtYmVkZGluZ3NdXQotIHNpc3RlciB0b29sIG9mIOKGkiBbW3NlbWFudGljX2lkZW50aWZpZXJfc2VhcmNoXV0KLSBtZXJnZSB0YXJnZXQ6IGBzZWFyY2hgIHdpdGggdHlwZSBgZmlsZSB8IGlkZW50aWZpZXIgfCBoeWJyaWRgIChUT0RPIHYxKQoKW1tzZW1hbnRpY19pZGVudGlmaWVyX3NlYXJjaF1dKHNyYy90b29scy9zZW1hbnRpYy1pZGVudGlmaWVycy50cykgLSBJZGVudGlmaWVyLWxldmVsIHJldHJpZXZhbCDigJQgZnVuY3Rpb25zLCBjbGFzc2VzLCB2YXJpYWJsZXMg4oCUIHdpdGggcmFua2VkIGNhbGwgc2l0ZXMuCgotIHJlbGllcyBvbiDihpIgW1tlbWJlZGRpbmdzXV0gW1twYXJzZXJdXQotIG1lcmdlIHRhcmdldDogYHNlYXJjaGAgKFRPRE8gdjEpCgpbW3NlbWFudGljX25hdmlnYXRlXV0oc3JjL3Rvb2xzL3NlbWFudGljLW5hdmlnYXRlLnRzKSAtIFNwZWN0cmFsIGNsdXN0ZXJpbmcgb3ZlciBmaWxlIGVtYmVkZGluZ3MgdG8gZ3JvdXAgc2VtYW50aWNhbGx5IHJlbGF0ZWQgZmlsZXMuCgotIHJlbGllcyBvbiDihpIgW1tjbHVzdGVyaW5nXV0gW1tlbWJlZGRpbmdzXV0KLSByZW5hbWUgdGFyZ2V0OiBgY2x1c3RlcmAgKFRPRE8gdjEpCgotLS0KCiMjIEFuYWx5c2lzIExheWVyCgpbW2dldF9ibGFzdF9yYWRpdXNdXShzcmMvdG9vbHMvYmxhc3QtcmFkaXVzLnRzKSAtIFRyYWNlcyBldmVyeSBmaWxlIGFuZCBsaW5lIHdoZXJlIGEgc3ltYm9sIGlzIGltcG9ydGVkIG9yIGNhbGxlZC4KCi0gcmVsaWVzIG9uIOKGkiBbW3dhbGtlcl1dIFtbcGFyc2VyXV0KLSBtdXN0IHJ1biBiZWZvcmUgW1twcm9wb3NlX2NvbW1pdF1dIG9uIGFueSBkZWxldGlvbiBvciByZW5hbWUKLSByZW5hbWUgdGFyZ2V0OiBgYmxhc3RfcmFkaXVzYCAoVE9ETyB2MSkKCltbcnVuX3N0YXRpY19hbmFseXNpc11dKHNyYy90b29scy9zdGF0aWMtYW5hbHlzaXMudHMpIC0gUnVucyBuYXRpdmUgbGludGVycyBhbmQgY29tcGlsZXJzOiBUeXBlU2NyaXB0LCBQeXRob24sIFJ1c3QsIEdvLgoKLSBydW4gYWZ0ZXIgZXZlcnkgW1twcm9wb3NlX2NvbW1pdF1dCi0gcmVuYW1lIHRhcmdldDogYGxpbnRgIChUT0RPIHYxKQotIFRPRE8gdjE6IGFkZCBwZXItZmlsZSBza2lsbCBzY29yZSAoY29tbWVudCBkZW5zaXR5LCBzdHlsZSBydWxlcykKCi0tLQoKIyMgQ29kZSBPcHMgTGF5ZXIKCltbcHJvcG9zZV9jb21taXRdXShzcmMvdG9vbHMvcHJvcG9zZS1jb21taXQudHMpIC0gVGhlIG9ubHkgY29ycmVjdCB3YXkgdG8gd3JpdGUgY29kZS4gVmFsaWRhdGVzLCBzYXZlcywgY3JlYXRlcyBhIHNoYWRvdyByZXN0b3JlIHBvaW50LgoKLSBkZXBlbmRzIG9uIOKGkiBbW2dpdF1dCi0gbXVzdCBiZSBwcmVjZWRlZCBieSBbW2dldF9ibGFzdF9yYWRpdXNdXSBmb3Igc3ltYm9sIHJlbW92YWwKLSBtdXN0IGJlIGZvbGxvd2VkIGJ5IFtbcnVuX3N0YXRpY19hbmFseXNpc11dCi0gcmVuYW1lIHRhcmdldDogYGNoZWNrcG9pbnRgIChUT0RPIHYxKQoKW1tnZXRfZmVhdHVyZV9odWJdXShzcmMvdG9vbHMvZmVhdHVyZS1odWIudHMpIC0gT2JzaWRpYW4tc3R5bGUgaHViIG5hdmlnYXRvci4gUmVhZHMgYC5tZGAgZmlsZXMgd2l0aCBgW1t3aWtpbGlua3NdXWAgbWFwcGluZyBmZWF0dXJlcyB0byBjb2RlLgoKLSByZWxpZXMgb24g4oaSIFtbaHViXV0KLSB0aGlzIGZpbGUgSVMgYSBmZWF0dXJlIGh1YiAoc2VsZi1yZWZlcmVudGlhbCkKLSByZW5hbWUgdGFyZ2V0OiBgZmluZF9odWJgIHdpdGggc2VtYW50aWMva2V5d29yZCBzZWFyY2ggKFRPRE8gdjEpCgotLS0KCiMjIFZlcnNpb24gQ29udHJvbCBMYXllcgoKW1tsaXN0X3Jlc3RvcmVfcG9pbnRzXV0oc3JjL2dpdC8pIC0gTGlzdHMgYWxsIHNoYWRvdyByZXN0b3JlIHBvaW50cyBjcmVhdGVkIGJ5IGBwcm9wb3NlX2NvbW1pdGAuCgotIGZlZWRzIGludG8g4oaSIFtbdW5kb19jaGFuZ2VdXQotIHJlbmFtZSB0YXJnZXQ6IGByZXN0b3JlX3BvaW50c2AgKFRPRE8gdjEpCgpbW3VuZG9fY2hhbmdlXV0oc3JjL2dpdC8pIC0gUmVzdG9yZXMgZmlsZXMgdG8gcHJlLUFJLWNoYW5nZSBzdGF0ZSB3aXRob3V0IHRvdWNoaW5nIGdpdCBoaXN0b3J5LgoKLSBkZXBlbmRzIG9uIOKGkiBbW2xpc3RfcmVzdG9yZV9wb2ludHNdXQotIHJlbmFtZSB0YXJnZXQ6IGByZXN0b3JlYCAoVE9ETyB2MSkKCi0tLQoKIyMgTWVtb3J5ICYgUkFHIExheWVyCgpbW3Vwc2VydF9tZW1vcnlfbm9kZV1dKHNyYy90b29scy9tZW1vcnktdG9vbHMudHMpIC0gQ3JlYXRlIG9yIHVwZGF0ZSBhIG1lbW9yeSBub2RlIHdpdGggYXV0by1nZW5lcmF0ZWQgZW1iZWRkaW5ncy4KCi0gcmVsaWVzIG9uIOKGkiBbW21lbW9yeS1ncmFwaF1dIFtbZW1iZWRkaW5nc11dCi0gY2FsbCBhdCB0YXNrIGVuZCB0byByZWNvcmQgd2hhdCB3YXMgbGVhcm5lZAotIHJlbmFtZSB0YXJnZXQ6IGBjcmVhdGVfbWVtb3J5YCAoVE9ETyB2MSkKCltbY3JlYXRlX3JlbGF0aW9uXV0oc3JjL3Rvb2xzL21lbW9yeS10b29scy50cykgLSBDcmVhdGUgdHlwZWQgZWRnZXM6IGByZWxhdGVzX3RvIHwgZGVwZW5kc19vbiB8IGltcGxlbWVudHMgfCByZWZlcmVuY2VzIHwgc2ltaWxhcl90byB8IGNvbnRhaW5zYC4KCi0gcmVsaWVzIG9uIOKGkiBbW21lbW9yeS1ncmFwaF1dCgpbW3NlYXJjaF9tZW1vcnlfZ3JhcGhdXShzcmMvdG9vbHMvbWVtb3J5LXRvb2xzLnRzKSAtIFNlbWFudGljIHNlYXJjaCArIDFzdC8ybmQtZGVncmVlIGdyYXBoIHRyYXZlcnNhbC4KCi0gcmVsaWVzIG9uIOKGkiBbW21lbW9yeS1ncmFwaF1dIFtbZW1iZWRkaW5nc11dCi0gcHJlZmVyIGByZXNvbHZlX2NvbnRleHRgIChwbWxsLW1lbW9yeS1tY3ApIHRvIGNoZWNrIHNob3J0LXRlcm0gS1YgZmlyc3QKLSByZW5hbWUgdGFyZ2V0OiBgc2VhcmNoX21lbW9yeWAgKFRPRE8gdjEpCgpbW2FkZF9pbnRlcmxpbmtlZF9jb250ZXh0XV0oc3JjL3Rvb2xzL21lbW9yeS10b29scy50cykgLSBCdWxrLWFkZCBub2RlcyB3aXRoIGF1dG8tc2ltaWxhcml0eSBsaW5raW5nIChjb3NpbmUg4omlIDAuNzIpLgoKLSByZWxpZXMgb24g4oaSIFtbbWVtb3J5LWdyYXBoXV0gW1tlbWJlZGRpbmdzXV0KLSByZW5hbWUgdGFyZ2V0OiBgYnVsa19tZW1vcnlgIChUT0RPIHYxKQoKW1tyZXRyaWV2ZV93aXRoX3RyYXZlcnNhbF1dKHNyYy90b29scy9tZW1vcnktdG9vbHMudHMpIC0gV2FsayBvdXR3YXJkIGZyb20gYSBub2RlLCByZXR1cm4gYWxsIG5laWdoYm9ycyBzY29yZWQgYnkgZGVjYXkgYW5kIGRlcHRoLgoKLSByZWxpZXMgb24g4oaSIFtbbWVtb3J5LWdyYXBoXV0KLSBwcmVmZXIgYHJlc29sdmVfY29udGV4dGAgKHBtbGwtbWVtb3J5LW1jcCkgdG8gY2hlY2sgc2hvcnQtdGVybSBLViBmaXJzdAotIHJlbmFtZSB0YXJnZXQ6IGBleHBsb3JlX21lbW9yeWAgKFRPRE8gdjEpCgpbW3BydW5lX3N0YWxlX2xpbmtzXV0oc3JjL3Rvb2xzL21lbW9yeS10b29scy50cykgLSBSZW1vdmUgZGVjYXllZCBlZGdlcyAoZV4o4oiSzrt0KSBiZWxvdyB0aHJlc2hvbGQpIGFuZCBvcnBoYW4gbm9kZXMuCgotIFRPRE8gdjE6IGF1dG8tdHJpZ2dlciBvbiBhbnkgbWVtb3J5IGdyYXBoIGFjY2VzczsgcmVtb3ZlIGFzIGV4cGxpY2l0IHRvb2wKCi0tLQoKIyMgQ29yZSBFbmdpbmUgKEludGVybmFsIE5vZGVzKQoKW1twYXJzZXJdXShzcmMvY29yZS9wYXJzZXIudHMpIC0gTXVsdGktbGFuZ3VhZ2UgQVNUIHBhcnNpbmcgdmlhIHRyZWUtc2l0dGVyICg0MyBleHRlbnNpb25zKS4gUG93ZXJzIGFsbCBzdHJ1Y3R1cmFsIHRvb2xzLgoKW1t3YWxrZXJdXShzcmMvY29yZS93YWxrZXIudHMpIC0gR2l0aWdub3JlLWF3YXJlIGZpbGVzeXN0ZW0gdHJhdmVyc2FsLiBFbnRyeSBwb2ludCBmb3IgYWxsIGZpbGUgc2NhbnMuCgpbW2VtYmVkZGluZ3NdXShzcmMvY29yZS9lbWJlZGRpbmdzLnRzKSAtIE9sbGFtYSAvIE9wZW5BSS1jb21wYXRpYmxlIHZlY3RvciBlbWJlZGRpbmdzIHdpdGggZGlzayBjYWNoZS4gUG93ZXJzIGFsbCBzZW1hbnRpYyB0b29scy4KCltbZW1iZWRkaW5nLXRyYWNrZXJdXShzcmMvY29yZS9lbWJlZGRpbmctdHJhY2tlci50cykgLSBSZWFsdGltZSBpbmNyZW1lbnRhbCB3YXRjaGVyIHRoYXQgcmVmcmVzaGVzIGVtYmVkZGluZ3Mgb24gZmlsZSBjaGFuZ2VzLgoKW1tjbHVzdGVyaW5nXV0oc3JjL2NvcmUvY2x1c3RlcmluZy50cykgLSBTcGVjdHJhbCBjbHVzdGVyaW5nIGFsZ29yaXRobSBwb3dlcmluZyBgc2VtYW50aWNfbmF2aWdhdGVgLgoKW1ttZW1vcnktZ3JhcGhdXShzcmMvY29yZS9tZW1vcnktZ3JhcGgudHMpIC0gSW4tbWVtb3J5IHByb3BlcnR5IGdyYXBoIHdpdGggSlNPTiBwZXJzaXN0ZW5jZSwgZGVjYXkgc2NvcmluZywgY29zaW5lIGF1dG8tbGlua2luZy4KCltbaHViXV0oc3JjL2NvcmUvaHViLnRzKSAtIFdpa2lsaW5rIHBhcnNlciBhbmQgaHViIG5hdmlnYXRvciBjb3JlLiBQb3dlcnMgYGdldF9mZWF0dXJlX2h1YmAuCgpbW2dpdF1dKHNyYy9naXQvKSAtIFNoYWRvdyByZXN0b3JlIHBvaW50IHN5c3RlbS4gUG93ZXJzIGBwcm9wb3NlX2NvbW1pdGAsIGBsaXN0X3Jlc3RvcmVfcG9pbnRzYCwgYHVuZG9fY2hhbmdlYC4KCi0tLQoKIyMgU29sdXRpb24gR3JhcGgg4oCUIEZ1bGwgRGVwZW5kZW5jeSBNYXAKCmBgYArilIDilIAgQ29yZSBkZXBlbmRlbmNpZXMg4pSA4pSACmdldF9jb250ZXh0X3RyZWUgICAgICAgIOKGkiBwYXJzZXIsIHdhbGtlcgpnZXRfZmlsZV9za2VsZXRvbiAgICAgICDihpIgcGFyc2VyCnNlbWFudGljX2NvZGVfc2VhcmNoICAgIOKGkiBlbWJlZGRpbmdzCnNlbWFudGljX2lkZW50aWZpZXIgICAgIOKGkiBlbWJlZGRpbmdzLCBwYXJzZXIKc2VtYW50aWNfbmF2aWdhdGUgICAgICAg4oaSIGVtYmVkZGluZ3MsIGNsdXN0ZXJpbmcKZ2V0X2JsYXN0X3JhZGl1cyAgICAgICAg4oaSIHdhbGtlciwgcGFyc2VyCnByb3Bvc2VfY29tbWl0ICAgICAgICAgIOKGkiBnaXQKZ2V0X2ZlYXR1cmVfaHViICAgICAgICAg4oaSIGh1Ygp1cHNlcnRfbWVtb3J5X25vZGUgICAgIOKGkiBtZW1vcnktZ3JhcGgsIGVtYmVkZGluZ3MKY3JlYXRlX3JlbGF0aW9uICAgICAgICAg4oaSIG1lbW9yeS1ncmFwaApzZWFyY2hfbWVtb3J5X2dyYXBoICAgICDihpIgbWVtb3J5LWdyYXBoLCBlbWJlZGRpbmdzCmFkZF9pbnRlcmxpbmtlZF9jb250ZXh0IOKGkiBtZW1vcnktZ3JhcGgsIGVtYmVkZGluZ3MKcmV0cmlldmVfd2l0aF90cmF2ZXJzYWwg4oaSIG1lbW9yeS1ncmFwaApwcnVuZV9zdGFsZV9saW5rcyAgICAgICDihpIgbWVtb3J5LWdyYXBoCgrilIDilIAgV29ya2Zsb3cgY2hhaW5zIOKUgOKUgApnZXRfY29udGV4dF90cmVlICAgICDilIDilIBmZWVkc19pbnRv4pSA4pSA4pa6IGdldF9maWxlX3NrZWxldG9uCmdldF9jb250ZXh0X3RyZWUgICAgIOKUgOKUgGZlZWRzX2ludG/ilIDilIDilrogc2VtYW50aWNfY29kZV9zZWFyY2gKZ2V0X2ZpbGVfc2tlbGV0b24gICAg4pSA4pSAZmVlZHNfaW50b+KUgOKUgOKWuiBnZXRfYmxhc3RfcmFkaXVzCmdldF9ibGFzdF9yYWRpdXMgICAgIOKUgOKUgGd1YXJkc+KUgOKUgOKUgOKUgOKUgOKUgOKWuiBwcm9wb3NlX2NvbW1pdApwcm9wb3NlX2NvbW1pdCAgICAgICDilIDilIB0cmlnZ2Vyc+KUgOKUgOKUgOKUgOKWuiBydW5fc3RhdGljX2FuYWx5c2lzCnByb3Bvc2VfY29tbWl0ICAgICAgIOKUgOKUgGNyZWF0ZXPilIDilIDilIDilIDilIDilrogbGlzdF9yZXN0b3JlX3BvaW50cwpsaXN0X3Jlc3RvcmVfcG9pbnRzICDilIDilIBlbmFibGVz4pSA4pSA4pSA4pSA4pSA4pa6IHVuZG9fY2hhbmdlCgrilIDilIAgTWVtb3J5IHdvcmtmbG93IOKUgOKUgAp0YXNrX3N0YXJ0ICAg4oaSIHNlYXJjaF9tZW1vcnlfZ3JhcGggKHJldHJpZXZlIHByaW9yIGNvbnRleHQpCnRhc2tfd29yayAgICDihpIgdXBzZXJ0X21lbW9yeV9ub2RlICsgY3JlYXRlX3JlbGF0aW9uIChidWlsZCBncmFwaCkKdGFza19lbmQgICAgIOKGkiB1cHNlcnRfbWVtb3J5X25vZGUgKHJlY29yZCBzb2x1dGlvbikKYXV0byAgICAgICAgIOKGkiBwcnVuZV9zdGFsZV9saW5rcyAoZGVjYXkgY2xlYW51cCkKYGBgCgotLS0KCiMjIHYxIFJlbmFtZSBNYXAKCnwgQ3VycmVudCBOYW1lIHwgdjEgTmFtZSB8IFN0YXR1cyB8CnwtLS18LS0tfC0tLXwKfCBgZ2V0X2NvbnRleHRfdHJlZWAgfCBgdHJlZWAgfCBbIF0gfAp8IGBnZXRfZmlsZV9za2VsZXRvbmAgfCBgc2tlbGV0b25gIHwgWyBdIHwKfCBgc2VtYW50aWNfY29kZV9zZWFyY2hgICsgYHNlbWFudGljX2lkZW50aWZpZXJfc2VhcmNoYCB8IGBzZWFyY2hgIChtZXJnZWQpIHwgWyBdIHwKfCBgc2VtYW50aWNfbmF2aWdhdGVgIHwgYGNsdXN0ZXJgIHwgWyBdIHwKfCBgZ2V0X2JsYXN0X3JhZGl1c2AgfCBgYmxhc3RfcmFkaXVzYCB8IFsgXSB8CnwgYHJ1bl9zdGF0aWNfYW5hbHlzaXNgIHwgYGxpbnRgIHwgWyBdIHwKfCBgcHJvcG9zZV9jb21taXRgIHwgYGNoZWNrcG9pbnRgIHwgWyBdIHwKfCBgZ2V0X2ZlYXR1cmVfaHViYCB8IGBmaW5kX2h1YmAgfCBbIF0gfAp8IGBsaXN0X3Jlc3RvcmVfcG9pbnRzYCB8IGByZXN0b3JlX3BvaW50c2AgfCBbIF0gfAp8IGB1bmRvX2NoYW5nZWAgfCBgcmVzdG9yZWAgfCBbIF0gfAp8IGB1cHNlcnRfbWVtb3J5X25vZGVgIHwgYGNyZWF0ZV9tZW1vcnlgIHwgWyBdIHwKfCBgc2VhcmNoX21lbW9yeV9ncmFwaGAgfCBgc2VhcmNoX21lbW9yeWAgfCBbIF0gfAp8IGByZXRyaWV2ZV93aXRoX3RyYXZlcnNhbGAgfCBgZXhwbG9yZV9tZW1vcnlgIHwgWyBdIHwKfCBgYWRkX2ludGVybGlua2VkX2NvbnRleHRgIHwgYGJ1bGtfbWVtb3J5YCB8IFsgXSB8CnwgYHBydW5lX3N0YWxlX2xpbmtzYCB8IF8oYXV0bywgcmVtb3ZlZCBhcyBleHBsaWNpdCB0b29sKV8gfCBbIF0gfAo= \ No newline at end of file diff --git a/FUNDING.json b/FUNDING.json new file mode 100644 index 0000000..f49e75c --- /dev/null +++ b/FUNDING.json @@ -0,0 +1,7 @@ +{ + "drips": { + "ethereum": { + "ownedBy": "0x64291eebc576C331ED6e8890Af176C079B9F5C7e" + } + } +} diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 7d90280..485cd56 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -26,20 +26,22 @@ The MCP server is built with TypeScript and communicates over stdio using the Mo - `static-analysis.ts` - Native linter runner (tsc, eslint, py_compile, cargo check, go vet). - `propose-commit.ts` - Code gatekeeper validating headers, FEATURE tag, no inline comments, nesting, file length. - `feature-hub.ts` - Obsidian-style feature hub navigator with bundled skeleton views. -- `memory-tools.ts` - Memory graph MCP wrappers (upsert, relate, search, prune, interlink, traverse). The long-term memory graph architecture is also adapted by the complementary [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) server (`npx pmll-memory-mcp`), which adds short-term KV memory and a solution engine — see [drQedwards/PPM](https://github.com/drQedwards/PPM). +- `memory-tools.ts` - Memory graph + short-term KV / solution-engine MCP wrappers (`init`/`peek`/`set`/`resolve`/`flush`, `resolve_context`, `promote_to_long_term`, `memory_status`). -The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Agents MUST use `search_memory_graph` at the start of every task to retrieve prior context, and persist learnings with `upsert_memory_node` and `create_relation` after completing work. This prevents redundant exploration and builds cumulative knowledge across sessions. +The memory graph is a **Retrieval-Augmented Generation (RAG)** system. Prefer `resolve_context` (KV then graph) and `peek` before expensive calls; persist learnings with `upsert_memory_node` / `promote_to_long_term`. Token burn reference (PPM three-way only): baseline ~302ms / peek-only ~26ms / combined ~36ms avg test exec; peek hits ~0ms — [three-way-speed-comparison.md](https://github.com/drQedwards/PPM/blob/main/mcp/benchmarks/three-way-speed-comparison.md). Optional complementary [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) for Q-promise / Python SQLite P0; retrieval harness is labeled P@k/R@k/MRR only (not agent accuracy). **Core Layer** (continued): - `hub.ts` - Wikilink parser for `[[path]]` links, cross-link tags, hub discovery, orphan detection. -- `memory-graph.ts` - In-memory property graph with JSON persistence, decay scoring, and auto-similarity edges. +- `memory-graph.ts` - In-memory property graph with JSON persistence, decay scoring, auto-similarity edges, and `mergeRankHits` for dual-layer resolve ranking. +- `short-term-kv.ts` - Session silo Map with `silo_size` LRU eviction; init/peek/set/flush + simple pending map. +- `solution-engine.ts` - `resolve_context` (KV then graph), `promote_to_long_term`, `memory_status`. **Git Layer** (`src/git/`): - `shadow.ts` - Shadow restore point system for undo without touching git history. -**Entry Point**: `src/index.ts` registers 17 MCP tools and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to `process.cwd()`). +**Entry Point**: `src/index.ts` registers the structural + memory MCP tools (including native short-term KV / solution engine) and starts the stdio transport. Accepts an optional CLI argument for the target project root directory (defaults to `process.cwd()`). ## Environment Variables diff --git a/README.md b/README.md index 3c650c6..33a3989 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 | Tool | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------- | +| `init` / `peek` / `set` / `resolve` / `flush` | Native short-term KV silo (`src/core/short-term-kv.ts`) with `silo_size` LRU eviction. | +| `resolve_context` | Dual-layer lookup: KV first, then memory graph via `mergeRankHits` unified ranking. | +| `promote_to_long_term` | Promote a KV entry into the persistent memory graph. | +| `memory_status` | Unified short-term + long-term status. | | `upsert_memory_node` | Create or update a memory node (concept, file, symbol, note) with auto-generated embeddings. | | `create_relation` | Create typed edges between nodes (relates_to, depends_on, implements, references, similar_to, contains). | | `search_memory_graph` | Semantic search with graph traversal — finds direct matches then walks 1st/2nd-degree neighbors. | @@ -50,7 +54,11 @@ https://github.com/user-attachments/assets/a97a451f-c9b4-468d-b036-15b65fc13e79 | `add_interlinked_context` | Bulk-add nodes with auto-similarity linking (cosine ≥ 0.72 creates edges automatically). | | `retrieve_with_traversal` | Start from a node and walk outward — returns all reachable neighbors scored by decay and depth. | -> **Complementary server:** [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) (`npx pmll-memory-mcp`) is a separate MCP server by [@drQedwards](https://github.com/drQedwards) that adapts Context+'s long-term memory graph and adds short-term KV context memory, Q-promise deduplication, and a solution engine on top. See [drQedwards/PPM](https://github.com/drQedwards/PPM) for details. +> **Core upgrade:** short-term KV + solution engine are now native (`short-term-kv.ts`, `solution-engine.ts`, `memory-tools.ts`). `mergeRankHits` supports unified ranking for dual-layer `resolve_context` (direct+neighbor by relevance) — separate from the pmll retrieval-stub harness ranking fix. +> +> **Token burn (PPM three-way, existing numbers):** [three-way-speed-comparison.md](https://github.com/drQedwards/PPM/blob/main/mcp/benchmarks/three-way-speed-comparison.md) — TS avg test exec baseline **~302ms**, Context+-only **~63ms**, peek-only **~26ms**, combined **~36ms**; peek hits **~0ms**. +> +> **Optional complementary:** [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) for full Q-promise / Python SQLite P0 — [PPM](https://github.com/drQedwards/PPM) / [pmll](https://github.com/drQedwards/pmll). Retrieval harness measures labeled **P@k / R@k / MRR** only — **not** agent task accuracy; no 99% claims. ## Setup diff --git a/agent-instructions.md b/agent-instructions.md index 804b184..4115452 100644 --- a/agent-instructions.md +++ b/agent-instructions.md @@ -4,16 +4,22 @@ Context+ gives you structural awareness of the entire codebase without reading every file. These tools replace your default search and read operations — use them as your primary interface to the codebase. -## PMLL Short-Term KV Memory (5 Tools) +## Short-Term KV Memory (native Context+) -Before every expensive MCP tool invocation, agents MUST use the PMLL short-term KV memory tools to check the cache. This eliminates redundant calls and accelerates task execution. These tools are provided by the [`pmll-memory-mcp`](https://www.npmjs.com/package/pmll-memory-mcp) server (see [mcp/README.md](./README.md)). +Before every expensive MCP tool invocation, agents MUST `peek` the short-term KV silo. This cuts redundant expensive calls. Native core files: `src/core/short-term-kv.ts`, `src/core/solution-engine.ts`, `src/core/memory-graph.ts` (`mergeRankHits` for dual-layer `resolve_context`), wrappers in `src/tools/memory-tools.ts`, registered in `src/index.ts`. + +**Token burn (existing PPM numbers only):** [three-way-speed-comparison.md](https://github.com/drQedwards/PPM/blob/main/mcp/benchmarks/three-way-speed-comparison.md) — TypeScript avg test execution: baseline **~302ms**, Context+-only **~63ms**, PMLL/peek-only **~26ms**, combined **~36ms**; peek cache hits **~0ms**. Prefer `peek` / `resolve_context` so graph work is paid once. + +**Retrieval (not agent accuracy):** labeled P@k / R@k / MRR harness on pmll/PPM ([benchmark_retrieval.md](https://github.com/drQedwards/pmll/blob/main/mcp/benchmarks/benchmark_retrieval.md) + [run_retrieval_stub.py](https://github.com/drQedwards/pmll/blob/main/mcp/benchmarks/run_retrieval_stub.py)) — toy stub; **not** agent task success; do not cite 99%/99.99%. + +**Optional complementary:** [pmll-memory-mcp](https://www.npmjs.com/package/pmll-memory-mcp) (`npx pmll-memory-mcp`) for full Q-promise / Python SQLite P0 durability — see [drQedwards/PPM](https://github.com/drQedwards/PPM) / [drQedwards/pmll](https://github.com/drQedwards/pmll). | Tool | Input | Description | |-----------|----------------------------------------------------|------------------------------------------------------------| -| `init` | `session_id: str`, `silo_size: int = 256` | Initialize a PMLL silo and Q-promise chain for the session | -| `peek` | `session_id: str`, `key: str` | Non-destructive cache lookup + Q-promise check (call before every expensive tool) | -| `set` | `session_id: str`, `key: str`, `value: str` | Store a key-value pair in the silo after a cache miss | -| `resolve` | `session_id: str`, `promise_id: str` | Check or resolve a Q-promise continuation | +| `init` | `session_id: str`, `silo_size: int = 256` | Initialize / reset the session KV silo | +| `peek` | `session_id: str`, `key: str` | Non-destructive cache lookup + pending check (before every expensive tool) | +| `set` | `session_id: str`, `key: str`, `value: str` | Store a key-value pair after a cache miss | +| `resolve` | `session_id: str`, `promise_id: str` | Check or resolve a pending continuation | | `flush` | `session_id: str` | Clear all silo slots at task completion | ### The `peek()` Pattern @@ -21,9 +27,9 @@ Before every expensive MCP tool invocation, agents MUST use the PMLL short-term Call `peek` before every expensive tool invocation: 1. **`init`** once at task start to set up the session silo -2. **`peek`** before each expensive call — if hit, use the cached value; if pending, wait on the Q-promise +2. **`peek`** before each expensive call — if hit, use the cached value; if pending, wait with `resolve` 3. **`set`** after a cache miss to populate the silo for future agents/subtasks -4. **`resolve`** to check or fulfill Q-promise continuations +4. **`resolve`** to check or fulfill pending continuations 5. **`flush`** at task end to clear all session slots This pattern ensures that Context+ tool results, Playwright page contents, and other expensive outputs are cached and reused across subtasks rather than re-fetched. @@ -63,18 +69,12 @@ You MUST use Context+ tools instead of native equivalents. Only fall back to nat | Tool | When to Use | |-----------|------------------------------------------------------------------------------| -| `init` | Once at task start. Set up the PMLL silo and Q-promise chain for the session.| -| `peek` | Before every expensive MCP tool call. Non-destructive cache + Q-promise check.| +| `init` | Once at task start. Reset the session short-term KV silo. | +| `peek` | Before every expensive MCP tool call. Non-destructive cache + pending check. | | `set` | After a cache miss. Store the result so future agents/subtasks skip the call. | -| `resolve` | When a Q-promise is pending. Check or fulfill the continuation. | +| `resolve` | When a key is pending. Check or fulfill the continuation. | | `flush` | At task end. Clear all silo slots for the session. | -### GraphQL - -| Tool | When to Use | -|-----------|------------------------------------------------------------------------------| -| `graphql` | Execute GraphQL queries/mutations against the memory store with optional PMLL cache integration. | - ### Context+ Structural Tools | Tool | When to Use | @@ -120,4 +120,4 @@ You MUST use Context+ tools instead of native equivalents. Only fall back to nat 6. Forgetting to call `init` at task start or `flush` at task end, causing silent cache misses or stale data across sessions 7. Storing frequently-accessed payloads only in short-term KV instead of promoting them to long-term memory with `promote_to_long_term` 8. Calling `search_memory_graph` or `retrieve_with_traversal` directly instead of using `resolve_context`, which checks both memory layers in one call -9. Ignoring Q-promise `pending` status from `peek` and re-issuing the same expensive call instead of waiting with `resolve` +9. Ignoring `pending` status from `peek` and re-issuing the same expensive call instead of waiting with `resolve` diff --git a/src/core/memory-graph.ts b/src/core/memory-graph.ts index 386b29b..0e99e3c 100644 --- a/src/core/memory-graph.ts +++ b/src/core/memory-graph.ts @@ -180,6 +180,23 @@ export async function createRelation(rootDir: string, sourceId: string, targetId return edge; } +/** Merge unique hits by id (fallback label), keep highest relevanceScore, sort desc, slice topK. + * Used by dual-layer resolve_context / ranked presentation — not a claim that searchGraph was buggy. + */ +export function mergeRankHits(hits: TraversalResult[], topK: number): TraversalResult[] { + const byKey = new Map(); + for (const hit of hits) { + const key = hit.node.id || hit.node.label; + const existing = byKey.get(key); + if (!existing || hit.relevanceScore > existing.relevanceScore) { + byKey.set(key, hit); + } + } + return [...byKey.values()] + .sort((a, b) => b.relevanceScore - a.relevanceScore) + .slice(0, topK); +} + export async function searchGraph(rootDir: string, query: string, maxDepth: number = 1, topK: number = 5, edgeFilter?: RelationType[]): Promise { const graph = await loadGraph(rootDir); const nodes = Object.values(graph.nodes); diff --git a/src/core/short-term-kv.ts b/src/core/short-term-kv.ts new file mode 100644 index 0000000..3a88803 --- /dev/null +++ b/src/core/short-term-kv.ts @@ -0,0 +1,200 @@ +// Session-scoped short-term KV silo with silo_size LRU eviction +// FEATURE: Short-Term KV — init/peek/set/flush + optional pending map for resolve + +/** + * In-process KV slot manager mirroring PMLL memory_silo_t / ppm mcp kv-store. + * Capacity: siloSize enforced on set(). New keys at capacity evict LRU. + * Optional pending map supports simple in-flight resolve without full Q-promise. + */ + +interface KVSlot { + index: number; + key: string; + value: string; + resolved: boolean; + /** Strictly increasing per-store access sequence (not wall clock). */ + lastAccessed: number; +} + +export type PeekResult = [boolean, string | null, number | null]; + +export type PeekContextResult = + | { hit: true; value: string; index: number } + | { hit: true; status: "pending"; promise_id: string } + | { hit: false }; + +export class ShortTermKVStore { + private _slots: Map = new Map(); + private _pending: Map = new Map(); // key → promise_id + private _nextIndex = 0; + private _accessSeq = 0; + siloSize: number; + + constructor(siloSize: number = 256) { + this.siloSize = Math.max(1, siloSize | 0); + } + + private _touch(slot: KVSlot): void { + this._accessSeq += 1; + slot.lastAccessed = this._accessSeq; + } + + peek(key: string): PeekResult { + const slot = this._slots.get(key); + if (slot !== undefined && slot.resolved) { + this._touch(slot); + return [true, slot.value, slot.index]; + } + return [false, null, null]; + } + + /** Two-stage guard: KV hit → pending map → miss. */ + peekContext(key: string): PeekContextResult { + const [hit, value, index] = this.peek(key); + if (hit && value !== null && index !== null) { + return { hit: true, value, index }; + } + const promiseId = this._pending.get(key); + if (promiseId !== undefined) { + return { hit: true, status: "pending", promise_id: promiseId }; + } + return { hit: false }; + } + + /** + * Store key/value. New keys at capacity evict the LRU entry. + * Clears any pending marker for the key. + */ + set(key: string, value: string): number { + this._pending.delete(key); + const existing = this._slots.get(key); + if (existing !== undefined) { + existing.value = value; + existing.resolved = true; + this._touch(existing); + return existing.index; + } + + if (this._slots.size >= this.siloSize) { + this._evictLru(); + } + + const index = this._nextIndex++; + const slot: KVSlot = { + index, + key, + value, + resolved: true, + lastAccessed: 0, + }; + this._touch(slot); + this._slots.set(key, slot); + return index; + } + + /** Mark key as in-flight so peek returns pending instead of miss. */ + markPending(key: string, promiseId?: string): string { + const id = promiseId ?? key; + if (!this._slots.has(key) || !this._slots.get(key)!.resolved) { + this._pending.set(key, id); + } + return id; + } + + /** + * Resolve a pending promise. If payload is provided, stores it via set. + * Returns status for the MCP resolve tool. + */ + resolve(promiseId: string, payload?: string): { status: "resolved" | "pending"; payload: string | null } { + for (const [key, id] of this._pending) { + if (id === promiseId || key === promiseId) { + if (payload !== undefined) { + this.set(key, payload); + return { status: "resolved", payload }; + } + const [hit, value] = this.peek(key); + if (hit && value !== null) { + this._pending.delete(key); + return { status: "resolved", payload: value }; + } + return { status: "pending", payload: null }; + } + } + // Also accept resolving by already-stored key matching promiseId + const [hit, value] = this.peek(promiseId); + if (hit && value !== null) { + return { status: "resolved", payload: value }; + } + return { status: "pending", payload: null }; + } + + private _evictLru(): void { + let victim: string | null = null; + let oldest = Infinity; + for (const [k, slot] of this._slots) { + if (slot.lastAccessed < oldest) { + oldest = slot.lastAccessed; + victim = k; + } + } + if (victim !== null) { + this._slots.delete(victim); + this._pending.delete(victim); + } + } + + flush(): number { + const count = this._slots.size; + this._slots.clear(); + this._pending.clear(); + this._nextIndex = 0; + this._accessSeq = 0; + return count; + } + + get size(): number { + return this._slots.size; + } + + get pendingCount(): number { + return this._pending.size; + } + + has(key: string): boolean { + return this._slots.has(key); + } +} + +const _sessionStores: Map = new Map(); + +export function getStore(sessionId: string, siloSize: number = 256): ShortTermKVStore { + let store = _sessionStores.get(sessionId); + if (store === undefined) { + store = new ShortTermKVStore(siloSize); + _sessionStores.set(sessionId, store); + } + return store; +} + +export function dropStore(sessionId: string): number { + const store = _sessionStores.get(sessionId); + if (store === undefined) return 0; + const count = store.size; + _sessionStores.delete(sessionId); + return count; +} + +/** Clear existing silo and return a fresh store (clear_on_init). */ +export function resetStore(sessionId: string, siloSize: number = 256): ShortTermKVStore { + _sessionStores.delete(sessionId); + const store = new ShortTermKVStore(siloSize); + _sessionStores.set(sessionId, store); + return store; +} + +export function listSessionIds(): string[] { + return [..._sessionStores.keys()]; +} + +/** Test/helper access to the registry. */ +export const _sessionStoresMap = _sessionStores; diff --git a/src/core/solution-engine.ts b/src/core/solution-engine.ts new file mode 100644 index 0000000..8ef4496 --- /dev/null +++ b/src/core/solution-engine.ts @@ -0,0 +1,81 @@ +// Unified short-term KV + long-term graph resolution +// FEATURE: Solution Engine — resolve_context, promote_to_long_term, memory_status + +import { ShortTermKVStore } from "./short-term-kv.js"; +import { + upsertNode, + searchGraph, + getGraphStats, + mergeRankHits, + type NodeType, +} from "./memory-graph.js"; + +const PROMOTION_THRESHOLD = 3; + +/** + * Resolve context by checking short-term KV first, then long-term graph. + * Graph hits use merge-ranked direct+neighbor results (pmll ranking fix). + */ +export async function resolveContext( + rootDir: string, + key: string, + store: ShortTermKVStore, +): Promise<{ source: "short_term" | "long_term" | "miss"; value: string | null; score: number }> { + const [hit, value] = store.peek(key); + if (hit && value !== null) { + return { source: "short_term", value, score: 1.0 }; + } + + const graphResult = await searchGraph(rootDir, key, 1, 5); + const ranked = mergeRankHits([...graphResult.direct, ...graphResult.neighbors], 1); + if (ranked.length > 0) { + const top = ranked[0]; + return { + source: "long_term", + value: top.node.content, + score: top.relevanceScore / 100, + }; + } + + return { source: "miss", value: null, score: 0 }; +} + +/** Promote a short-term KV entry into the persistent memory graph. */ +export async function promoteToLongTerm( + rootDir: string, + key: string, + value: string, + nodeType: NodeType = "concept", + metadata?: Record, +): Promise<{ promoted: boolean; nodeId: string | null }> { + const node = await upsertNode(rootDir, nodeType, key, value, { + ...(metadata ?? {}), + promoted_from: "short_term_kv", + }); + return { promoted: true, nodeId: node.id }; +} + +/** Unified status view of both memory layers. */ +export async function getMemoryStatus( + rootDir: string, + store: ShortTermKVStore, +): Promise<{ + shortTerm: { slots: number; siloSize: number; pending: number }; + longTerm: { nodes: number; edges: number; types: Record }; + promotionThreshold: number; +}> { + const stats = await getGraphStats(rootDir); + return { + shortTerm: { + slots: store.size, + siloSize: store.siloSize, + pending: store.pendingCount, + }, + longTerm: { + nodes: stats.nodes, + edges: stats.edges, + types: stats.types, + }, + promotionThreshold: PROMOTION_THRESHOLD, + }; +} diff --git a/src/index.ts b/src/index.ts index 7dd839a..4703856 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,12 @@ import { proposeCommit } from "./tools/propose-commit.js"; import { listRestorePoints, restorePoint } from "./git/shadow.js"; import { semanticNavigate } from "./tools/semantic-navigate.js"; import { getFeatureHub } from "./tools/feature-hub.js"; -import { toolUpsertMemoryNode, toolCreateRelation, toolSearchMemoryGraph, toolPruneStaleLinks, toolAddInterlinkedContext, toolRetrieveWithTraversal } from "./tools/memory-tools.js"; +import { + toolUpsertMemoryNode, toolCreateRelation, toolSearchMemoryGraph, toolPruneStaleLinks, + toolAddInterlinkedContext, toolRetrieveWithTraversal, + toolInitSilo, toolPeek, toolSet, toolResolve, toolFlush, + toolResolveContext, toolPromoteToLongTerm, toolMemoryStatus, +} from "./tools/memory-tools.js"; type AgentTarget = "claude" | "cursor" | "vscode" | "windsurf" | "opencode"; @@ -540,6 +545,112 @@ server.tool( })), ); + +server.tool( + "init", + "Initialize the short-term KV memory silo for an agent session. Call once at task start. " + + "Resets any existing silo for the session_id. Use peek/set/flush with the same session_id.", + { + session_id: z.string().describe("Unique identifier for this agent task/session."), + silo_size: z.number().optional().describe("Max KV slots before LRU eviction. Default: 256."), + }, + withRequestActivity(async ({ session_id, silo_size }) => ({ + content: [{ type: "text" as const, text: await toolInitSilo({ sessionId: session_id, siloSize: silo_size }) }], + })), +); + +server.tool( + "peek", + "Non-destructive short-term KV lookup. Call BEFORE every expensive MCP tool to avoid redundant work. " + + "Returns cache hit, pending (in-flight), or miss.", + { + session_id: z.string().describe("Session id from init."), + key: z.string().describe("Context key to look up."), + }, + withRequestActivity(async ({ session_id, key }) => ({ + content: [{ type: "text" as const, text: await toolPeek({ sessionId: session_id, key }) }], + })), +); + +server.tool( + "set", + "Store a key-value pair in the short-term KV silo after a cache miss / expensive tool call.", + { + session_id: z.string().describe("Session id from init."), + key: z.string().describe("Context key."), + value: z.string().describe("Value to cache."), + }, + withRequestActivity(async ({ session_id, key, value }) => ({ + content: [{ type: "text" as const, text: await toolSet({ sessionId: session_id, key, value }) }], + })), +); + +server.tool( + "resolve", + "Check or fulfill a pending short-term continuation (simple pending map). " + + "Pass payload to store the resolved value.", + { + session_id: z.string().describe("Session id from init."), + promise_id: z.string().describe("Pending promise id (often the same as the key)."), + payload: z.string().optional().describe("Optional payload to resolve and store."), + }, + withRequestActivity(async ({ session_id, promise_id, payload }) => ({ + content: [{ type: "text" as const, text: await toolResolve({ sessionId: session_id, promiseId: promise_id, payload }) }], + })), +); + +server.tool( + "flush", + "Clear all short-term KV slots for a session. Call at task end.", + { + session_id: z.string().describe("Session id from init."), + }, + withRequestActivity(async ({ session_id }) => ({ + content: [{ type: "text" as const, text: await toolFlush({ sessionId: session_id }) }], + })), +); + +server.tool( + "resolve_context", + "Unified context lookup: short-term KV first, then long-term memory graph (merge-ranked). " + + "Prefer this over calling search_memory_graph alone when you may already have a cached value.", + { + session_id: z.string().describe("Session id from init."), + key: z.string().describe("Context key / search query."), + }, + withRequestActivity(async ({ session_id, key }) => ({ + content: [{ type: "text" as const, text: await toolResolveContext({ rootDir: ROOT_DIR, sessionId: session_id, key }) }], + })), +); + +server.tool( + "promote_to_long_term", + "Promote a short-term KV entry into the persistent long-term memory graph so it survives flush.", + { + session_id: z.string().describe("Session id from init."), + key: z.string().describe("Key/label for the memory node."), + value: z.string().describe("Content to store in long-term memory."), + node_type: z.enum(["concept", "file", "symbol", "note"]).optional().describe("Node type. Default: concept."), + }, + withRequestActivity(async ({ session_id, key, value, node_type }) => ({ + content: [{ + type: "text" as const, + text: await toolPromoteToLongTerm({ rootDir: ROOT_DIR, sessionId: session_id, key, value, nodeType: node_type }), + }], + })), +); + +server.tool( + "memory_status", + "Unified view of short-term KV silo and long-term memory graph for a session.", + { + session_id: z.string().describe("Session id from init."), + }, + withRequestActivity(async ({ session_id }) => ({ + content: [{ type: "text" as const, text: await toolMemoryStatus({ rootDir: ROOT_DIR, sessionId: session_id }) }], + })), +); + async function main() { const args = process.argv.slice(2); if (args[0] === "init") { diff --git a/src/tools/memory-tools.ts b/src/tools/memory-tools.ts index 925ef3c..e8dbfc3 100644 --- a/src/tools/memory-tools.ts +++ b/src/tools/memory-tools.ts @@ -2,7 +2,9 @@ // FEATURE: Memory Tools — upsert, relate, search, prune, interlink, traverse import type { NodeType, RelationType, TraversalResult } from "../core/memory-graph.js"; -import { upsertNode, createRelation, searchGraph, pruneStaleLinks, addInterlinkedContext, retrieveWithTraversal, getGraphStats } from "../core/memory-graph.js"; +import { upsertNode, createRelation, searchGraph, pruneStaleLinks, addInterlinkedContext, retrieveWithTraversal, getGraphStats, mergeRankHits } from "../core/memory-graph.js"; +import { getStore, resetStore, dropStore, type PeekContextResult } from "../core/short-term-kv.js"; +import { resolveContext, promoteToLongTerm, getMemoryStatus } from "../core/solution-engine.js"; export interface UpsertMemoryNodeOptions { rootDir: string; @@ -83,15 +85,21 @@ export async function toolCreateRelation(options: CreateRelationOptions): Promis export async function toolSearchMemoryGraph(options: SearchMemoryGraphOptions): Promise { const result = await searchGraph(options.rootDir, options.query, options.maxDepth, options.topK, options.edgeFilter); - if (result.direct.length === 0) return `No memory nodes found for: "${options.query}"\nGraph has ${result.totalNodes} nodes, ${result.totalEdges} edges.`; - - const sections: string[] = [`Memory Graph Search: "${options.query}"`, `Graph: ${result.totalNodes} nodes, ${result.totalEdges} edges\n`]; + if (result.direct.length === 0 && result.neighbors.length === 0) { + return `No memory nodes found for: "${options.query}"\nGraph has ${result.totalNodes} nodes, ${result.totalEdges} edges.`; + } - sections.push("Direct Matches:"); - for (const hit of result.direct) sections.push(formatTraversalResult(hit)); + const topK = options.topK ?? 5; + const ranked = mergeRankHits([...result.direct, ...result.neighbors], topK); + const sections: string[] = [ + `Memory Graph Search: "${options.query}"`, + `Graph: ${result.totalNodes} nodes, ${result.totalEdges} edges`, + `Ranked (merged direct+neighbor by relevance, top ${topK}):\n`, + ]; + for (const hit of ranked) sections.push(formatTraversalResult(hit)); if (result.neighbors.length > 0) { - sections.push("\nLinked Neighbors:"); + sections.push("\nLinked Neighbors (full):"); for (const neighbor of result.neighbors) sections.push(formatTraversalResult(neighbor)); } @@ -140,3 +148,83 @@ export async function toolRetrieveWithTraversal(options: RetrieveWithTraversalOp return sections.join("\n"); } + +// --- Short-term KV + solution engine wrappers (ported from ppm/mcp) --- + +export async function toolInitSilo(options: { sessionId: string; siloSize?: number }): Promise { + const store = resetStore(options.sessionId, options.siloSize ?? 256); + return JSON.stringify({ + status: "initialized", + session_id: options.sessionId, + silo_size: store.siloSize, + cleared: true, + }); +} + +export async function toolPeek(options: { sessionId: string; key: string }): Promise { + const store = getStore(options.sessionId); + const result: PeekContextResult = store.peekContext(options.key); + return JSON.stringify(result); +} + +export async function toolSet(options: { sessionId: string; key: string; value: string }): Promise { + const store = getStore(options.sessionId); + const index = store.set(options.key, options.value); + return JSON.stringify({ status: "stored", index }); +} + +export async function toolResolve(options: { + sessionId: string; + promiseId: string; + payload?: string; +}): Promise { + const store = getStore(options.sessionId); + const result = store.resolve(options.promiseId, options.payload); + return JSON.stringify(result); +} + +export async function toolFlush(options: { sessionId: string }): Promise { + const cleared = dropStore(options.sessionId); + return JSON.stringify({ status: "flushed", cleared_count: cleared }); +} + +export async function toolResolveContext(options: { + rootDir: string; + sessionId: string; + key: string; +}): Promise { + const store = getStore(options.sessionId); + const result = await resolveContext(options.rootDir, options.key, store); + return JSON.stringify(result); +} + +export async function toolPromoteToLongTerm(options: { + rootDir: string; + sessionId: string; + key: string; + value: string; + nodeType?: NodeType; +}): Promise { + const store = getStore(options.sessionId); + if (!store.has(options.key)) { + store.set(options.key, options.value); + } + const result = await promoteToLongTerm( + options.rootDir, + options.key, + options.value, + options.nodeType ?? "concept", + ); + return JSON.stringify(result); +} + +export async function toolMemoryStatus(options: { + rootDir: string; + sessionId: string; +}): Promise { + const store = getStore(options.sessionId); + const result = await getMemoryStatus(options.rootDir, store); + return JSON.stringify(result, null, 2); +} + +export { mergeRankHits }; diff --git a/test/main/short-term-kv.test.mjs b/test/main/short-term-kv.test.mjs new file mode 100644 index 0000000..f8b1cfb --- /dev/null +++ b/test/main/short-term-kv.test.mjs @@ -0,0 +1,296 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "fs/promises"; +import { join, resolve } from "path"; +import { Ollama } from "ollama"; + +const { + ShortTermKVStore, + getStore, + dropStore, + resetStore, + _sessionStoresMap, +} = await import("../../build/core/short-term-kv.js"); + +const { + mergeRankHits, + searchGraph, + upsertNode, + createRelation, +} = await import("../../build/core/memory-graph.js"); + +const { + resolveContext, + promoteToLongTerm, + getMemoryStatus, +} = await import("../../build/core/solution-engine.js"); + +const { + toolInitSilo, + toolPeek, + toolSet, + toolFlush, + toolResolveContext, + toolMemoryStatus, +} = await import("../../build/tools/memory-tools.js"); + +beforeEach(() => { + _sessionStoresMap.clear(); +}); + +describe("ShortTermKVStore — peek/set/flush", () => { + it("peek miss on empty store", () => { + const store = new ShortTermKVStore(); + const [hit, value, index] = store.peek("missing"); + assert.equal(hit, false); + assert.equal(value, null); + assert.equal(index, null); + }); + + it("peek hit after set", () => { + const store = new ShortTermKVStore(); + store.set("url", "https://example.com"); + const [hit, value, index] = store.peek("url"); + assert.equal(hit, true); + assert.equal(value, "https://example.com"); + assert.equal(index, 0); + }); + + it("set update keeps same index", () => { + const store = new ShortTermKVStore(); + store.set("k", "v1"); + const idx = store.set("k", "v2"); + assert.equal(idx, 0); + assert.equal(store.peek("k")[1], "v2"); + }); + + it("flush clears all slots", () => { + const store = new ShortTermKVStore(); + store.set("a", "1"); + store.set("b", "2"); + assert.equal(store.flush(), 2); + assert.equal(store.size, 0); + assert.equal(store.peek("a")[0], false); + }); +}); + +describe("ShortTermKVStore — LRU eviction", () => { + it("evicts least-recently-used when over silo_size", () => { + const store = new ShortTermKVStore(2); + store.set("a", "1"); + store.set("b", "2"); + store.peek("a"); // touch a so b is LRU + store.set("c", "3"); + assert.equal(store.size, 2); + assert.equal(store.has("b"), false); + assert.equal(store.has("a"), true); + assert.equal(store.has("c"), true); + }); +}); + +describe("ShortTermKVStore — pending / resolve", () => { + it("peekContext returns pending when marked", () => { + const store = new ShortTermKVStore(); + store.markPending("job", "job"); + const result = store.peekContext("job"); + assert.equal(result.hit, true); + assert.equal(result.status, "pending"); + assert.equal(result.promise_id, "job"); + }); + + it("resolve with payload stores value and clears pending", () => { + const store = new ShortTermKVStore(); + store.markPending("job", "job"); + const resolved = store.resolve("job", "done"); + assert.equal(resolved.status, "resolved"); + assert.equal(resolved.payload, "done"); + const peek = store.peekContext("job"); + assert.equal(peek.hit, true); + assert.equal(peek.value, "done"); + }); +}); + +describe("session helpers", () => { + it("resetStore clears on init", () => { + const first = resetStore("s1", 8); + first.set("x", "1"); + const second = resetStore("s1", 16); + assert.equal(second.size, 0); + assert.equal(second.siloSize, 16); + assert.equal(getStore("s1").peek("x")[0], false); + }); + + it("dropStore removes session", () => { + resetStore("s2").set("a", "b"); + assert.equal(dropStore("s2"), 1); + assert.equal(dropStore("s2"), 0); + }); +}); + +describe("mergeRankHits unified ranking", () => { + it("sorts by relevanceScore desc and dedupes by id", () => { + const mk = (id, label, score, depth = 0) => ({ + node: { id, label, type: "concept", content: label, embedding: [], createdAt: 0, lastAccessed: 0, accessCount: 1, metadata: {} }, + depth, + pathRelations: [], + relevanceScore: score, + }); + const ranked = mergeRankHits([ + mk("a", "A", 50, 0), + mk("b", "B", 90, 1), + mk("a", "A", 40, 0), + mk("c", "C", 70, 1), + ], 2); + assert.equal(ranked.length, 2); + assert.equal(ranked[0].node.id, "b"); + assert.equal(ranked[1].node.id, "c"); + }); + + it("high-scoring neighbor outranks lower direct in top_k", () => { + const mk = (id, label, score, depth) => ({ + node: { id, label, type: "note", content: label, embedding: [], createdAt: 0, lastAccessed: 0, accessCount: 1, metadata: {} }, + depth, + pathRelations: [], + relevanceScore: score, + }); + // Unified ranking: neighbor with higher score wins top_k slot + const ranked = mergeRankHits([ + mk("direct-low", "DirectLow", 60, 0), + mk("neighbor-high", "NeighborHigh", 95, 1), + ], 1); + assert.equal(ranked[0].node.id, "neighbor-high"); + assert.equal(ranked[0].depth, 1); + }); +}); + +describe("tool wrappers — KV", () => { + it("init/peek/set/flush round-trip", async () => { + const init = JSON.parse(await toolInitSilo({ sessionId: "t1", siloSize: 32 })); + assert.equal(init.status, "initialized"); + assert.equal(init.silo_size, 32); + + assert.equal(JSON.parse(await toolPeek({ sessionId: "t1", key: "k" })).hit, false); + const stored = JSON.parse(await toolSet({ sessionId: "t1", key: "k", value: "v" })); + assert.equal(stored.status, "stored"); + const hit = JSON.parse(await toolPeek({ sessionId: "t1", key: "k" })); + assert.equal(hit.hit, true); + assert.equal(hit.value, "v"); + + const flushed = JSON.parse(await toolFlush({ sessionId: "t1" })); + assert.equal(flushed.status, "flushed"); + assert.equal(flushed.cleared_count, 1); + }); +}); + +const FIXTURE = resolve("test/_short_term_kv_fixtures"); + +function mockEmbedding() { + const original = Ollama.prototype.embed; + Ollama.prototype.embed = async function ({ input }) { + const batch = Array.isArray(input) ? input : [input]; + return { + embeddings: batch.map((text) => { + const vec = new Array(64).fill(0); + for (let i = 0; i < Math.min(text.length, 64); i++) { + vec[i] = (text.charCodeAt(i) % 100) / 100; + } + const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)); + return norm > 0 ? vec.map((v) => v / norm) : vec; + }), + }; + }; + return () => { Ollama.prototype.embed = original; }; +} + +describe("solution engine", () => { + beforeEach(async () => { + await rm(FIXTURE, { recursive: true, force: true }); + await mkdir(join(FIXTURE, ".mcp_data"), { recursive: true }); + _sessionStoresMap.clear(); + }); + + it("resolveContext hits short-term first", async () => { + const store = resetStore("sol1"); + store.set("auth", "cached-auth-flow"); + const result = await resolveContext(FIXTURE, "auth", store); + assert.equal(result.source, "short_term"); + assert.equal(result.value, "cached-auth-flow"); + assert.equal(result.score, 1); + }); + + it("resolveContext falls back to long-term graph", async () => { + const restore = mockEmbedding(); + try { + const store = resetStore("sol2"); + await upsertNode(FIXTURE, "concept", "Auth Flow", "Handles login sessions"); + const result = await resolveContext(FIXTURE, "Auth Flow login", store); + assert.equal(result.source, "long_term"); + assert.ok(result.value); + assert.ok(result.score > 0); + } finally { + restore(); + } + }); + + it("promoteToLongTerm creates graph node", async () => { + const restore = mockEmbedding(); + try { + const promoted = await promoteToLongTerm(FIXTURE, "Promoted Key", "Promoted content", "note"); + assert.equal(promoted.promoted, true); + assert.ok(promoted.nodeId?.startsWith("mn-")); + } finally { + restore(); + } + }); + + it("memory_status reports both layers", async () => { + const restore = mockEmbedding(); + try { + const store = resetStore("sol3", 64); + store.set("a", "1"); + await upsertNode(FIXTURE, "note", "Status Note", "status content"); + const status = await getMemoryStatus(FIXTURE, store); + assert.equal(status.shortTerm.slots, 1); + assert.equal(status.shortTerm.siloSize, 64); + assert.ok(status.longTerm.nodes >= 1); + assert.equal(status.promotionThreshold, 3); + } finally { + restore(); + } + }); + + it("toolResolveContext JSON round-trip", async () => { + await toolInitSilo({ sessionId: "sol4" }); + await toolSet({ sessionId: "sol4", key: "x", value: "y" }); + const raw = await toolResolveContext({ rootDir: FIXTURE, sessionId: "sol4", key: "x" }); + const parsed = JSON.parse(raw); + assert.equal(parsed.source, "short_term"); + const status = JSON.parse(await toolMemoryStatus({ rootDir: FIXTURE, sessionId: "sol4" })); + assert.equal(status.shortTerm.slots, 1); + }); +}); + +describe("searchGraph + mergeRankHits integration", () => { + it("returns neighbors that can outrank weak directs in merged top_k helper", async () => { + const dir = resolve("test/_rank_fix_fixtures"); + await rm(dir, { recursive: true, force: true }); + await mkdir(join(dir, ".mcp_data"), { recursive: true }); + const restore = mockEmbedding(); + try { + const root = await upsertNode(dir, "concept", "RankRootExactQueryZZ", "RankRootExactQueryZZ unique seed"); + const neighbor = await upsertNode(dir, "concept", "RankNeighbor", "unrelated other topic"); + await createRelation(dir, root.id, neighbor.id, "relates_to", 1.0); + const result = await searchGraph(dir, "RankRootExactQueryZZ", 1, 5); + assert.ok(result.direct.length + result.neighbors.length >= 1); + const merged = mergeRankHits([...result.direct, ...result.neighbors], 5); + assert.ok(merged.length >= 1); + // Scores must be non-increasing + for (let i = 1; i < merged.length; i++) { + assert.ok(merged[i - 1].relevanceScore >= merged[i].relevanceScore); + } + } finally { + restore(); + await rm(dir, { recursive: true, force: true }); + } + }); +});