my harness for niri
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

codex vibecoded embeddings

+1137 -13
+9
.env.example
··· 5 5 # Enable/disable model reasoning traces (thinking) across requests + stream output. 6 6 ENABLE_THINKING=true 7 7 8 + # ── Memory embeddings ──────────────────────────────────────────────────────── 9 + # Used for semantic memory reranking and low-information chatter suppression. 10 + # The sqlite-vec table is fixed at 3072 dimensions; changing dimensions requires 11 + # deleting/rebuilding memory_chunk_vec, memory_prototype_vec, and embedding meta. 12 + EMBEDDING_API_KEY= 13 + EMBEDDING_BASE_URL=https://openrouter.ai/api/v1 14 + EMBEDDING_MODEL=google/gemini-embedding-2-preview 15 + EMBEDDING_DIMENSIONS=3072 16 + 8 17 # Used when the primary endpoint is unreachable or rate-limited (429/5xx). 9 18 # Supports any OpenAI-compatible provider (OpenRouter, LM Studio, etc.). 10 19 FALLBACK_OPENAI_BASE_URL=http://localhost:1234/v1
+548 -1
package-lock.json
··· 15 15 "dependencies": { 16 16 "@fastify/static": "^9.1.3", 17 17 "@niri/chat-client": "*", 18 + "@openrouter/sdk": "^0.12.21", 18 19 "better-sqlite3": "^12.8.0", 19 20 "discord.js": "^14.25.1", 20 21 "fastify": "^5.8.4", 21 22 "node-pty": "^1.1.0", 22 - "openai": "^6.33.0" 23 + "openai": "^6.33.0", 24 + "sqlite-vec": "^0.1.9" 23 25 }, 24 26 "devDependencies": { 25 27 "@types/better-sqlite3": "^7.6.13", ··· 74 76 "version": "7.29.0", 75 77 "dev": true, 76 78 "license": "MIT", 79 + "peer": true, 77 80 "dependencies": { 78 81 "@babel/code-frame": "^7.29.0", 79 82 "@babel/generator": "^7.29.0", ··· 439 442 "url": "https://github.com/discordjs/discord.js?sponsor" 440 443 } 441 444 }, 445 + "node_modules/@esbuild/aix-ppc64": { 446 + "version": "0.27.7", 447 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", 448 + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", 449 + "cpu": [ 450 + "ppc64" 451 + ], 452 + "dev": true, 453 + "license": "MIT", 454 + "optional": true, 455 + "os": [ 456 + "aix" 457 + ], 458 + "engines": { 459 + "node": ">=18" 460 + } 461 + }, 462 + "node_modules/@esbuild/android-arm": { 463 + "version": "0.27.7", 464 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", 465 + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", 466 + "cpu": [ 467 + "arm" 468 + ], 469 + "dev": true, 470 + "license": "MIT", 471 + "optional": true, 472 + "os": [ 473 + "android" 474 + ], 475 + "engines": { 476 + "node": ">=18" 477 + } 478 + }, 479 + "node_modules/@esbuild/android-arm64": { 480 + "version": "0.27.7", 481 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", 482 + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", 483 + "cpu": [ 484 + "arm64" 485 + ], 486 + "dev": true, 487 + "license": "MIT", 488 + "optional": true, 489 + "os": [ 490 + "android" 491 + ], 492 + "engines": { 493 + "node": ">=18" 494 + } 495 + }, 496 + "node_modules/@esbuild/android-x64": { 497 + "version": "0.27.7", 498 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", 499 + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", 500 + "cpu": [ 501 + "x64" 502 + ], 503 + "dev": true, 504 + "license": "MIT", 505 + "optional": true, 506 + "os": [ 507 + "android" 508 + ], 509 + "engines": { 510 + "node": ">=18" 511 + } 512 + }, 513 + "node_modules/@esbuild/darwin-arm64": { 514 + "version": "0.27.7", 515 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", 516 + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", 517 + "cpu": [ 518 + "arm64" 519 + ], 520 + "dev": true, 521 + "license": "MIT", 522 + "optional": true, 523 + "os": [ 524 + "darwin" 525 + ], 526 + "engines": { 527 + "node": ">=18" 528 + } 529 + }, 530 + "node_modules/@esbuild/darwin-x64": { 531 + "version": "0.27.7", 532 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", 533 + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", 534 + "cpu": [ 535 + "x64" 536 + ], 537 + "dev": true, 538 + "license": "MIT", 539 + "optional": true, 540 + "os": [ 541 + "darwin" 542 + ], 543 + "engines": { 544 + "node": ">=18" 545 + } 546 + }, 547 + "node_modules/@esbuild/freebsd-arm64": { 548 + "version": "0.27.7", 549 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", 550 + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", 551 + "cpu": [ 552 + "arm64" 553 + ], 554 + "dev": true, 555 + "license": "MIT", 556 + "optional": true, 557 + "os": [ 558 + "freebsd" 559 + ], 560 + "engines": { 561 + "node": ">=18" 562 + } 563 + }, 564 + "node_modules/@esbuild/freebsd-x64": { 565 + "version": "0.27.7", 566 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", 567 + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", 568 + "cpu": [ 569 + "x64" 570 + ], 571 + "dev": true, 572 + "license": "MIT", 573 + "optional": true, 574 + "os": [ 575 + "freebsd" 576 + ], 577 + "engines": { 578 + "node": ">=18" 579 + } 580 + }, 581 + "node_modules/@esbuild/linux-arm": { 582 + "version": "0.27.7", 583 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", 584 + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", 585 + "cpu": [ 586 + "arm" 587 + ], 588 + "dev": true, 589 + "license": "MIT", 590 + "optional": true, 591 + "os": [ 592 + "linux" 593 + ], 594 + "engines": { 595 + "node": ">=18" 596 + } 597 + }, 598 + "node_modules/@esbuild/linux-arm64": { 599 + "version": "0.27.7", 600 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", 601 + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", 602 + "cpu": [ 603 + "arm64" 604 + ], 605 + "dev": true, 606 + "license": "MIT", 607 + "optional": true, 608 + "os": [ 609 + "linux" 610 + ], 611 + "engines": { 612 + "node": ">=18" 613 + } 614 + }, 615 + "node_modules/@esbuild/linux-ia32": { 616 + "version": "0.27.7", 617 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", 618 + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", 619 + "cpu": [ 620 + "ia32" 621 + ], 622 + "dev": true, 623 + "license": "MIT", 624 + "optional": true, 625 + "os": [ 626 + "linux" 627 + ], 628 + "engines": { 629 + "node": ">=18" 630 + } 631 + }, 632 + "node_modules/@esbuild/linux-loong64": { 633 + "version": "0.27.7", 634 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", 635 + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", 636 + "cpu": [ 637 + "loong64" 638 + ], 639 + "dev": true, 640 + "license": "MIT", 641 + "optional": true, 642 + "os": [ 643 + "linux" 644 + ], 645 + "engines": { 646 + "node": ">=18" 647 + } 648 + }, 649 + "node_modules/@esbuild/linux-mips64el": { 650 + "version": "0.27.7", 651 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", 652 + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", 653 + "cpu": [ 654 + "mips64el" 655 + ], 656 + "dev": true, 657 + "license": "MIT", 658 + "optional": true, 659 + "os": [ 660 + "linux" 661 + ], 662 + "engines": { 663 + "node": ">=18" 664 + } 665 + }, 666 + "node_modules/@esbuild/linux-ppc64": { 667 + "version": "0.27.7", 668 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", 669 + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", 670 + "cpu": [ 671 + "ppc64" 672 + ], 673 + "dev": true, 674 + "license": "MIT", 675 + "optional": true, 676 + "os": [ 677 + "linux" 678 + ], 679 + "engines": { 680 + "node": ">=18" 681 + } 682 + }, 683 + "node_modules/@esbuild/linux-riscv64": { 684 + "version": "0.27.7", 685 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", 686 + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", 687 + "cpu": [ 688 + "riscv64" 689 + ], 690 + "dev": true, 691 + "license": "MIT", 692 + "optional": true, 693 + "os": [ 694 + "linux" 695 + ], 696 + "engines": { 697 + "node": ">=18" 698 + } 699 + }, 700 + "node_modules/@esbuild/linux-s390x": { 701 + "version": "0.27.7", 702 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", 703 + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", 704 + "cpu": [ 705 + "s390x" 706 + ], 707 + "dev": true, 708 + "license": "MIT", 709 + "optional": true, 710 + "os": [ 711 + "linux" 712 + ], 713 + "engines": { 714 + "node": ">=18" 715 + } 716 + }, 442 717 "node_modules/@esbuild/linux-x64": { 443 718 "version": "0.27.7", 444 719 "cpu": [ ··· 454 729 "node": ">=18" 455 730 } 456 731 }, 732 + "node_modules/@esbuild/netbsd-arm64": { 733 + "version": "0.27.7", 734 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", 735 + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", 736 + "cpu": [ 737 + "arm64" 738 + ], 739 + "dev": true, 740 + "license": "MIT", 741 + "optional": true, 742 + "os": [ 743 + "netbsd" 744 + ], 745 + "engines": { 746 + "node": ">=18" 747 + } 748 + }, 749 + "node_modules/@esbuild/netbsd-x64": { 750 + "version": "0.27.7", 751 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", 752 + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", 753 + "cpu": [ 754 + "x64" 755 + ], 756 + "dev": true, 757 + "license": "MIT", 758 + "optional": true, 759 + "os": [ 760 + "netbsd" 761 + ], 762 + "engines": { 763 + "node": ">=18" 764 + } 765 + }, 766 + "node_modules/@esbuild/openbsd-arm64": { 767 + "version": "0.27.7", 768 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", 769 + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", 770 + "cpu": [ 771 + "arm64" 772 + ], 773 + "dev": true, 774 + "license": "MIT", 775 + "optional": true, 776 + "os": [ 777 + "openbsd" 778 + ], 779 + "engines": { 780 + "node": ">=18" 781 + } 782 + }, 783 + "node_modules/@esbuild/openbsd-x64": { 784 + "version": "0.27.7", 785 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", 786 + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", 787 + "cpu": [ 788 + "x64" 789 + ], 790 + "dev": true, 791 + "license": "MIT", 792 + "optional": true, 793 + "os": [ 794 + "openbsd" 795 + ], 796 + "engines": { 797 + "node": ">=18" 798 + } 799 + }, 800 + "node_modules/@esbuild/openharmony-arm64": { 801 + "version": "0.27.7", 802 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", 803 + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", 804 + "cpu": [ 805 + "arm64" 806 + ], 807 + "dev": true, 808 + "license": "MIT", 809 + "optional": true, 810 + "os": [ 811 + "openharmony" 812 + ], 813 + "engines": { 814 + "node": ">=18" 815 + } 816 + }, 817 + "node_modules/@esbuild/sunos-x64": { 818 + "version": "0.27.7", 819 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", 820 + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", 821 + "cpu": [ 822 + "x64" 823 + ], 824 + "dev": true, 825 + "license": "MIT", 826 + "optional": true, 827 + "os": [ 828 + "sunos" 829 + ], 830 + "engines": { 831 + "node": ">=18" 832 + } 833 + }, 834 + "node_modules/@esbuild/win32-arm64": { 835 + "version": "0.27.7", 836 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", 837 + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", 838 + "cpu": [ 839 + "arm64" 840 + ], 841 + "dev": true, 842 + "license": "MIT", 843 + "optional": true, 844 + "os": [ 845 + "win32" 846 + ], 847 + "engines": { 848 + "node": ">=18" 849 + } 850 + }, 851 + "node_modules/@esbuild/win32-ia32": { 852 + "version": "0.27.7", 853 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", 854 + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", 855 + "cpu": [ 856 + "ia32" 857 + ], 858 + "dev": true, 859 + "license": "MIT", 860 + "optional": true, 861 + "os": [ 862 + "win32" 863 + ], 864 + "engines": { 865 + "node": ">=18" 866 + } 867 + }, 868 + "node_modules/@esbuild/win32-x64": { 869 + "version": "0.27.7", 870 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", 871 + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", 872 + "cpu": [ 873 + "x64" 874 + ], 875 + "dev": true, 876 + "license": "MIT", 877 + "optional": true, 878 + "os": [ 879 + "win32" 880 + ], 881 + "engines": { 882 + "node": ">=18" 883 + } 884 + }, 457 885 "node_modules/@fastify/accept-negotiator": { 458 886 "version": "2.0.1", 459 887 "funding": [ ··· 667 1095 "resolved": "apps/web", 668 1096 "link": true 669 1097 }, 1098 + "node_modules/@openrouter/sdk": { 1099 + "version": "0.12.24", 1100 + "resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-0.12.24.tgz", 1101 + "integrity": "sha512-pHybqvkWZUvg/uM99+ShHgDKg/iA3MU2MZdlZvoenmK/3cnG6jW1zROb+tyd6iEAZwcqb2+2QoahgLbJgo/9Sw==", 1102 + "hasInstallScript": true, 1103 + "license": "Apache-2.0", 1104 + "dependencies": { 1105 + "zod": "^3.25.0 || ^4.0.0" 1106 + } 1107 + }, 670 1108 "node_modules/@pinojs/redact": { 671 1109 "version": "0.4.0", 672 1110 "license": "MIT" ··· 811 1249 "node_modules/@types/node": { 812 1250 "version": "25.5.2", 813 1251 "license": "MIT", 1252 + "peer": true, 814 1253 "dependencies": { 815 1254 "undici-types": "~7.18.0" 816 1255 } ··· 818 1257 "node_modules/@types/react": { 819 1258 "version": "19.2.14", 820 1259 "license": "MIT", 1260 + "peer": true, 821 1261 "dependencies": { 822 1262 "csstype": "^3.2.2" 823 1263 } ··· 1034 1474 } 1035 1475 ], 1036 1476 "license": "MIT", 1477 + "peer": true, 1037 1478 "dependencies": { 1038 1479 "baseline-browser-mapping": "^2.10.12", 1039 1480 "caniuse-lite": "^1.0.30001782", ··· 1571 2012 "version": "1.0.0", 1572 2013 "license": "MIT" 1573 2014 }, 2015 + "node_modules/fsevents": { 2016 + "version": "2.3.3", 2017 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 2018 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 2019 + "dev": true, 2020 + "hasInstallScript": true, 2021 + "license": "MIT", 2022 + "optional": true, 2023 + "os": [ 2024 + "darwin" 2025 + ], 2026 + "engines": { 2027 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 2028 + } 2029 + }, 1574 2030 "node_modules/gensync": { 1575 2031 "version": "1.0.0-beta.2", 1576 2032 "dev": true, ··· 2877 3333 "version": "4.0.4", 2878 3334 "dev": true, 2879 3335 "license": "MIT", 3336 + "peer": true, 2880 3337 "engines": { 2881 3338 "node": ">=12" 2882 3339 }, ··· 3018 3475 "node_modules/react": { 3019 3476 "version": "19.2.4", 3020 3477 "license": "MIT", 3478 + "peer": true, 3021 3479 "engines": { 3022 3480 "node": ">=0.10.0" 3023 3481 } ··· 3405 3863 "node": ">= 10.x" 3406 3864 } 3407 3865 }, 3866 + "node_modules/sqlite-vec": { 3867 + "version": "0.1.9", 3868 + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", 3869 + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", 3870 + "license": "MIT OR Apache", 3871 + "optionalDependencies": { 3872 + "sqlite-vec-darwin-arm64": "0.1.9", 3873 + "sqlite-vec-darwin-x64": "0.1.9", 3874 + "sqlite-vec-linux-arm64": "0.1.9", 3875 + "sqlite-vec-linux-x64": "0.1.9", 3876 + "sqlite-vec-windows-x64": "0.1.9" 3877 + } 3878 + }, 3879 + "node_modules/sqlite-vec-darwin-arm64": { 3880 + "version": "0.1.9", 3881 + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", 3882 + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", 3883 + "cpu": [ 3884 + "arm64" 3885 + ], 3886 + "license": "MIT OR Apache", 3887 + "optional": true, 3888 + "os": [ 3889 + "darwin" 3890 + ] 3891 + }, 3892 + "node_modules/sqlite-vec-darwin-x64": { 3893 + "version": "0.1.9", 3894 + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", 3895 + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", 3896 + "cpu": [ 3897 + "x64" 3898 + ], 3899 + "license": "MIT OR Apache", 3900 + "optional": true, 3901 + "os": [ 3902 + "darwin" 3903 + ] 3904 + }, 3905 + "node_modules/sqlite-vec-linux-arm64": { 3906 + "version": "0.1.9", 3907 + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", 3908 + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", 3909 + "cpu": [ 3910 + "arm64" 3911 + ], 3912 + "license": "MIT OR Apache", 3913 + "optional": true, 3914 + "os": [ 3915 + "linux" 3916 + ] 3917 + }, 3918 + "node_modules/sqlite-vec-linux-x64": { 3919 + "version": "0.1.9", 3920 + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", 3921 + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", 3922 + "cpu": [ 3923 + "x64" 3924 + ], 3925 + "license": "MIT OR Apache", 3926 + "optional": true, 3927 + "os": [ 3928 + "linux" 3929 + ] 3930 + }, 3931 + "node_modules/sqlite-vec-windows-x64": { 3932 + "version": "0.1.9", 3933 + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", 3934 + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", 3935 + "cpu": [ 3936 + "x64" 3937 + ], 3938 + "license": "MIT OR Apache", 3939 + "optional": true, 3940 + "os": [ 3941 + "win32" 3942 + ] 3943 + }, 3408 3944 "node_modules/statuses": { 3409 3945 "version": "2.0.2", 3410 3946 "license": "MIT", ··· 3543 4079 "version": "4.21.0", 3544 4080 "dev": true, 3545 4081 "license": "MIT", 4082 + "peer": true, 3546 4083 "dependencies": { 3547 4084 "esbuild": "~0.27.0", 3548 4085 "get-tsconfig": "^4.7.5" ··· 3738 4275 "version": "7.3.2", 3739 4276 "dev": true, 3740 4277 "license": "MIT", 4278 + "peer": true, 3741 4279 "dependencies": { 3742 4280 "esbuild": "^0.27.0", 3743 4281 "fdir": "^6.5.0", ··· 3848 4386 "version": "3.1.1", 3849 4387 "dev": true, 3850 4388 "license": "ISC" 4389 + }, 4390 + "node_modules/zod": { 4391 + "version": "4.4.1", 4392 + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", 4393 + "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", 4394 + "license": "MIT", 4395 + "funding": { 4396 + "url": "https://github.com/sponsors/colinhacks" 4397 + } 3851 4398 }, 3852 4399 "node_modules/zwitch": { 3853 4400 "version": "2.0.4",
+2 -1
package.json
··· 31 31 "discord.js": "^14.25.1", 32 32 "fastify": "^5.8.4", 33 33 "node-pty": "^1.1.0", 34 - "openai": "^6.33.0" 34 + "openai": "^6.33.0", 35 + "sqlite-vec": "^0.1.9" 35 36 }, 36 37 "devDependencies": { 37 38 "@types/better-sqlite3": "^7.6.13",
+47
src/db.ts
··· 1 1 import Database from "better-sqlite3" 2 2 import fs from "fs" 3 3 import path from "path" 4 + import * as sqliteVec from "sqlite-vec" 4 5 import { fileURLToPath } from "url" 5 6 6 7 const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home") 7 8 const DB_PATH = path.join(HOME_DIR, "niri.db") 9 + export const MEMORY_EMBEDDING_DIMENSIONS = 3072 8 10 9 11 let db: Database.Database 12 + let vecAvailable = false 10 13 11 14 function ensureWritableDirOrThrow(dirPath: string, purpose: string): void { 12 15 try { ··· 47 50 48 51 db.pragma("journal_mode = WAL") 49 52 db.pragma("foreign_keys = ON") 53 + try { 54 + sqliteVec.load(db) 55 + vecAvailable = true 56 + } catch (err: any) { 57 + vecAvailable = false 58 + console.warn(`[db] sqlite-vec unavailable: ${err?.message ?? String(err)}`) 59 + } 50 60 51 61 db.exec(` 52 62 create table if not exists conversations ( ··· 186 196 insert into memory_chunks_fts(rowid, title, heading_path, chunk_text, tags) 187 197 values (new.id, new.title, new.heading_path, new.chunk_text, new.tags); 188 198 end; 199 + 200 + create table if not exists memory_embedding_meta ( 201 + chunk_id integer primary key references memory_chunks(id) on delete cascade, 202 + model text not null, 203 + dimensions integer not null, 204 + content_hash text not null, 205 + updated_at text not null default (datetime('now')) 206 + ); 207 + 208 + create index if not exists idx_memory_embedding_meta_model 209 + on memory_embedding_meta(model, dimensions); 210 + 211 + create table if not exists memory_embedding_prototypes ( 212 + id integer primary key, 213 + name text not null unique, 214 + category text not null, 215 + model text not null, 216 + dimensions integer not null, 217 + content_hash text not null, 218 + updated_at text not null default (datetime('now')) 219 + ); 189 220 `) 221 + 222 + if (vecAvailable) { 223 + db.exec(` 224 + create virtual table if not exists memory_chunk_vec using vec0( 225 + embedding float[${MEMORY_EMBEDDING_DIMENSIONS}] distance_metric=cosine 226 + ); 227 + 228 + create virtual table if not exists memory_prototype_vec using vec0( 229 + embedding float[${MEMORY_EMBEDDING_DIMENSIONS}] distance_metric=cosine 230 + ); 231 + `) 232 + } 190 233 191 234 console.log("[db] ready") 192 235 } ··· 224 267 if (!db) throw new Error("Database not initialized") 225 268 return db 226 269 } 270 + 271 + export function isVecAvailable(): boolean { 272 + return vecAvailable 273 + }
+39
src/embeddings.ts
··· 1 + import OpenAI from "openai" 2 + import { MEMORY_EMBEDDING_DIMENSIONS } from "./db.js" 3 + 4 + export const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL ?? "google/gemini-embedding-2-preview" 5 + export const EMBEDDING_DIMENSIONS = parseInt( 6 + process.env.EMBEDDING_DIMENSIONS ?? String(MEMORY_EMBEDDING_DIMENSIONS), 7 + 10, 8 + ) 9 + 10 + const EMBEDDING_BASE_URL = process.env.EMBEDDING_BASE_URL ?? "https://openrouter.ai/api/v1" 11 + const EMBEDDING_API_KEY = process.env.EMBEDDING_API_KEY 12 + 13 + const embeddingClient = EMBEDDING_API_KEY 14 + ? new OpenAI({ 15 + baseURL: EMBEDDING_BASE_URL, 16 + apiKey: EMBEDDING_API_KEY, 17 + defaultHeaders: { 18 + ...(process.env.EMBEDDING_OPENAI_REFERER ? { "HTTP-Referer": process.env.EMBEDDING_OPENAI_REFERER } : {}), 19 + ...(process.env.EMBEDDING_TITLE ? { "X-Title": process.env.EMBEDDING_TITLE } : {}), 20 + }, 21 + }) 22 + : null 23 + 24 + export function embeddingsConfigured(): boolean { 25 + return Boolean(embeddingClient) && EMBEDDING_DIMENSIONS > 0 26 + } 27 + 28 + export async function embedTexts(texts: string[]): Promise<number[][]> { 29 + if (!embeddingClient || texts.length === 0) return [] 30 + 31 + const response = await embeddingClient.embeddings.create({ 32 + model: EMBEDDING_MODEL, 33 + input: texts, 34 + dimensions: EMBEDDING_DIMENSIONS, 35 + encoding_format: "float", 36 + }) 37 + 38 + return response.data.map((item) => item.embedding) 39 + }
+40 -1
src/memory.test.ts
··· 27 27 - [channel/staying up till 1 billion oclock/#niri] [2026-05-01 03:11:14.553Z] @meowskullz: awa 28 28 29 29 pending preview: 30 - - (none)` 30 + - 1499679672404021248 [mention] [channel/staying up till 1 billion oclock/#niri] [2026-05-01 03:11:14.553Z] @meowskullz: awa` 31 31 32 32 assert.deepEqual(__memoryTest.memoryQueryForUserMessage(batch), { 33 33 sender: "meowskullz", 34 34 source: "channel/staying up till 1 billion oclock/#niri", 35 35 body: "awa", 36 + }) 37 + }) 38 + 39 + test("latestMemoryRecallQuery skips ambient discord batch without pending items", () => { 40 + const batch = `[user/discord] [discord batch] 2026-05-01T03:10:50.162Z -> 2026-05-01T03:11:22.198Z 41 + new_messages=1 channels=1 pending_inbox=0 scope=configured+dm 42 + 43 + recent messages: 44 + - [channel/meowskullz's server/#ai-sister-yap] [2026-05-01 08:01:35.639Z] @rose: foxie emoji 45 + 46 + pending preview: 47 + - (none)` 48 + 49 + const conversation: Message[] = [ 50 + { role: "user", content: "what did we talk about when lisya was buying monero" }, 51 + { role: "assistant", content: "..." }, 52 + { role: "user", content: batch }, 53 + ] 54 + 55 + assert.equal( 56 + __memoryTest.latestMemoryRecallQuery(conversation), 57 + "what did we talk about when lisya was buying monero", 58 + ) 59 + }) 60 + 61 + test("memoryQueryForUserMessage ignores ambient discord batch recent messages", () => { 62 + const batch = `[user/discord] [discord batch] 2026-05-01T03:10:50.162Z -> 2026-05-01T03:11:22.198Z 63 + new_messages=1 channels=1 pending_inbox=0 scope=configured+dm 64 + 65 + recent messages: 66 + - [channel/meowskullz's server/#ai-sister-yap] [2026-05-01 08:01:35.639Z] @rose: foxie emoji 67 + 68 + pending preview: 69 + - (none)` 70 + 71 + assert.deepEqual(__memoryTest.memoryQueryForUserMessage(batch), { 72 + sender: null, 73 + source: null, 74 + body: batch, 36 75 }) 37 76 }) 38 77
+452 -10
src/memory.ts
··· 3 3 import { createHash } from "crypto" 4 4 import { fileURLToPath } from "url" 5 5 import type { Message } from "./types.js" 6 - import { getDb } from "./db.js" 6 + import { getDb, isVecAvailable, MEMORY_EMBEDDING_DIMENSIONS } from "./db.js" 7 + import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL, embeddingsConfigured, embedTexts } from "./embeddings.js" 7 8 import { recordMetric } from "./metrics.js" 8 9 9 10 const HOME_DIR = path.resolve(fileURLToPath(import.meta.url), "../../home") ··· 22 23 const MEMORY_RECALL_PER_EXTRA_PERSON_CHARS = 400 23 24 const MEMORY_QUERY_TOKEN_LIMIT = 12 24 25 const MEMORY_RECALL_COOLDOWN_TURNS = 7 26 + const MEMORY_EMBEDDING_BATCH_SIZE = 24 27 + const MEMORY_SEMANTIC_MIN_SIMILARITY = 0.18 28 + const MEMORY_SEMANTIC_STRONG_SIMILARITY = 0.32 29 + const MEMORY_CHATTER_SIMILARITY_THRESHOLD = 0.74 30 + const MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD = 0.55 25 31 const SCHEDULED_HEARTBEAT_CONTENT = "Scheduled heartbeat." 32 + const MEMORY_EMBEDDING_PROTOTYPES = [ 33 + { id: 1, name: "affection-love", category: "chatter", text: "i love you so much sweetie <33" }, 34 + { id: 2, name: "cat-greeting", category: "chatter", text: "boop mraow meow hi sweetie" }, 35 + { id: 3, name: "celebration", category: "chatter", text: "yay yayy lets gooooo <33" }, 36 + { id: 4, name: "goodnight", category: "chatter", text: "goodnight sweet dreams rest well" }, 37 + { id: 101, name: "who-person", category: "recall_intent", text: "who is this person what do i know about them" }, 38 + { id: 102, name: "past-event", category: "recall_intent", text: "what happened before remember when that event happened" }, 39 + { id: 103, name: "task-context", category: "recall_intent", text: "what context do i need for this task or project" }, 40 + { id: 104, name: "system-lesson", category: "recall_intent", text: "what lesson or instruction should i remember here" }, 41 + ] as const 26 42 const MEMORY_STOP_WORDS = new Set([ 27 43 "a", 28 44 "an", ··· 103 119 headingPath: string | null 104 120 text: string 105 121 rank: number 122 + semanticDistance?: number 123 + semanticSimilarity?: number 106 124 } 107 125 108 126 export type MemorySearchResult = { ··· 140 158 senderMatch: boolean 141 159 } 142 160 161 + type MemoryEmbeddingRow = { 162 + chunkId: number 163 + path: string 164 + kind: MemoryKind 165 + documentTitle: string 166 + title: string 167 + headingPath: string | null 168 + text: string 169 + tags: string | null 170 + model: string | null 171 + dimensions: number | null 172 + contentHash: string | null 173 + } 174 + 175 + type SemanticQuerySignal = { 176 + vector: number[] 177 + chatterSimilarity: number | null 178 + recallIntentSimilarity: number | null 179 + } 180 + 143 181 export type AliasMap = Record<string, string[]> 144 182 145 183 function normalizeText(value: string): string { ··· 297 335 return createHash("sha1").update(content).digest("hex") 298 336 } 299 337 338 + function embeddingInputHash(content: string): string { 339 + return createHash("sha256").update(content).digest("hex") 340 + } 341 + 342 + function vectorParam(vector: number[]): Float32Array { 343 + return new Float32Array(vector) 344 + } 345 + 346 + function embeddingTextForChunk(row: { 347 + path: string 348 + kind: MemoryKind 349 + documentTitle: string 350 + title: string 351 + headingPath: string | null 352 + text: string 353 + tags?: string | null 354 + }): string { 355 + const relativePath = path.relative(HOME_DIR, row.path) 356 + return [ 357 + `kind: ${row.kind}`, 358 + `file: ${relativePath}`, 359 + `document: ${row.documentTitle}`, 360 + `title: ${row.title}`, 361 + row.headingPath ? `section: ${row.headingPath}` : null, 362 + row.tags ? `tags: ${row.tags}` : null, 363 + "", 364 + row.text, 365 + ] 366 + .filter((part): part is string => part !== null) 367 + .join("\n") 368 + } 369 + 300 370 function chunkLargeSection(text: string, maxChars = 900): string[] { 301 371 const paragraphs = text 302 372 .split(/\n\s*\n/g) ··· 405 475 if (content.startsWith(MEMORY_RECALL_HEADER)) return true 406 476 if (content.startsWith("[system]")) return true 407 477 if (content.includes("scan snapshot:") && !content.includes("[discord batch]")) return true 478 + if (/\[discord batch\]/i.test(content) && conciseDiscordBatchMemoryQuery(content) === null) return true 408 479 return false 409 480 } 410 481 ··· 506 577 const withoutWakeEnvelope = raw.replace(/^\[(wake|incoming|harness restarted)[^\n]*\]\s*/gi, "").trim() 507 578 if (!/\[discord batch\]/i.test(withoutWakeEnvelope)) return null 508 579 509 - const recent = extractBulletSection(withoutWakeEnvelope, "recent messages") 510 580 const pending = extractBulletSection(withoutWakeEnvelope, "pending preview") 511 - const selected = (recent.length > 0 ? recent : pending).slice(-3) 581 + const selected = pending.filter((entry) => !/^\(none\)$/i.test(entry)).slice(-3) 512 582 if (selected.length === 0) return null 513 583 514 584 const senders: string[] = [] 515 585 const sources: string[] = [] 516 586 const bodies: string[] = [] 517 - const entryPattern = /^\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*(.*)$/i 587 + const entryPattern = /(?:^|\s)\[([^\]]+)\]\s+\[[^\]]+\]\s+@([^:]+):\s*(.*)$/i 518 588 for (const entry of selected) { 519 589 const match = entry.match(entryPattern) 520 590 if (!match) { ··· 798 868 })() 799 869 800 870 if (updates.length === 0 && removedPaths.length === 0) { 871 + await syncMemoryEmbeddings() 801 872 return 802 873 } 874 + 875 + await syncMemoryEmbeddings() 876 + } 877 + 878 + let embeddingSkipWarned = false 879 + 880 + async function syncMemoryEmbeddings(): Promise<void> { 881 + if (!isVecAvailable()) return 882 + if (!embeddingsConfigured()) { 883 + if (!embeddingSkipWarned) { 884 + console.warn("[memory] embeddings disabled: set EMBEDDING_API_KEY") 885 + embeddingSkipWarned = true 886 + } 887 + return 888 + } 889 + if (EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) { 890 + if (!embeddingSkipWarned) { 891 + console.warn( 892 + `[memory] embeddings disabled: EMBEDDING_DIMENSIONS=${EMBEDDING_DIMENSIONS} but sqlite-vec table is ${MEMORY_EMBEDDING_DIMENSIONS}`, 893 + ) 894 + embeddingSkipWarned = true 895 + } 896 + return 897 + } 898 + 899 + const db = getDb() 900 + try { 901 + await syncMemoryEmbeddingPrototypes() 902 + } catch (err: any) { 903 + console.warn(`[memory] prototype embedding sync failed: ${err?.message ?? String(err)}`) 904 + return 905 + } 906 + db.prepare("delete from memory_embedding_meta where chunk_id not in (select id from memory_chunks)").run() 907 + db.prepare("delete from memory_chunk_vec where rowid not in (select id from memory_chunks)").run() 908 + 909 + const rows = db 910 + .prepare(` 911 + select 912 + c.id as chunkId, 913 + d.path as path, 914 + d.kind as kind, 915 + d.title as documentTitle, 916 + c.title as title, 917 + c.heading_path as headingPath, 918 + c.chunk_text as text, 919 + c.tags as tags, 920 + m.model as model, 921 + m.dimensions as dimensions, 922 + m.content_hash as contentHash 923 + from memory_chunks c 924 + join memory_documents d on d.id = c.document_id 925 + left join memory_embedding_meta m on m.chunk_id = c.id 926 + order by d.kind, d.path, c.chunk_index 927 + `) 928 + .all() as MemoryEmbeddingRow[] 929 + 930 + const pending = rows 931 + .map((row) => { 932 + const text = embeddingTextForChunk(row) 933 + return { ...row, embeddingText: text, embeddingHash: embeddingInputHash(text) } 934 + }) 935 + .filter( 936 + (row) => 937 + row.model !== EMBEDDING_MODEL || 938 + row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 939 + row.contentHash !== row.embeddingHash, 940 + ) 941 + 942 + if (pending.length === 0) return 943 + 944 + const upsertMeta = db.prepare(` 945 + insert into memory_embedding_meta (chunk_id, model, dimensions, content_hash, updated_at) 946 + values (?, ?, ?, ?, datetime('now')) 947 + on conflict(chunk_id) do update set 948 + model = excluded.model, 949 + dimensions = excluded.dimensions, 950 + content_hash = excluded.content_hash, 951 + updated_at = datetime('now') 952 + `) 953 + const upsertVector = db.prepare("insert or replace into memory_chunk_vec(rowid, embedding) values (?, ?)") 954 + 955 + let embedded = 0 956 + for (let i = 0; i < pending.length; i += MEMORY_EMBEDDING_BATCH_SIZE) { 957 + const batch = pending.slice(i, i + MEMORY_EMBEDDING_BATCH_SIZE) 958 + let vectors: number[][] 959 + try { 960 + vectors = await embedTexts(batch.map((row) => row.embeddingText)) 961 + } catch (err: any) { 962 + console.warn(`[memory] embedding batch failed: ${err?.message ?? String(err)}`) 963 + return 964 + } 965 + 966 + db.transaction(() => { 967 + batch.forEach((row, index) => { 968 + const vector = vectors[index] 969 + if (!vector) return 970 + if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 971 + throw new Error(`embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 972 + } 973 + upsertVector.run(BigInt(row.chunkId), vectorParam(vector)) 974 + upsertMeta.run(row.chunkId, EMBEDDING_MODEL, MEMORY_EMBEDDING_DIMENSIONS, row.embeddingHash) 975 + embedded += 1 976 + }) 977 + })() 978 + } 979 + 980 + console.log(`[memory] embedded chunks=${embedded} model=${EMBEDDING_MODEL} dimensions=${MEMORY_EMBEDDING_DIMENSIONS}`) 981 + } 982 + 983 + async function syncMemoryEmbeddingPrototypes(): Promise<void> { 984 + const db = getDb() 985 + const rows = db 986 + .prepare("select id, name, category, model, dimensions, content_hash as contentHash from memory_embedding_prototypes") 987 + .all() as Array<{ 988 + id: number 989 + name: string 990 + category: string 991 + model: string 992 + dimensions: number 993 + contentHash: string 994 + }> 995 + const known = new Map(rows.map((row) => [row.id, row])) 996 + const pending = MEMORY_EMBEDDING_PROTOTYPES.map((prototype) => ({ 997 + ...prototype, 998 + hash: embeddingInputHash(`${prototype.category}\n${prototype.name}\n${prototype.text}`), 999 + })).filter((prototype) => { 1000 + const row = known.get(prototype.id) 1001 + return ( 1002 + !row || 1003 + row.name !== prototype.name || 1004 + row.category !== prototype.category || 1005 + row.model !== EMBEDDING_MODEL || 1006 + row.dimensions !== MEMORY_EMBEDDING_DIMENSIONS || 1007 + row.contentHash !== prototype.hash 1008 + ) 1009 + }) 1010 + 1011 + if (pending.length === 0) return 1012 + 1013 + const vectors = await embedTexts(pending.map((prototype) => prototype.text)) 1014 + const upsertPrototype = db.prepare(` 1015 + insert into memory_embedding_prototypes (id, name, category, model, dimensions, content_hash, updated_at) 1016 + values (?, ?, ?, ?, ?, ?, datetime('now')) 1017 + on conflict(id) do update set 1018 + name = excluded.name, 1019 + category = excluded.category, 1020 + model = excluded.model, 1021 + dimensions = excluded.dimensions, 1022 + content_hash = excluded.content_hash, 1023 + updated_at = datetime('now') 1024 + `) 1025 + const upsertVector = db.prepare("insert or replace into memory_prototype_vec(rowid, embedding) values (?, ?)") 1026 + 1027 + db.transaction(() => { 1028 + pending.forEach((prototype, index) => { 1029 + const vector = vectors[index] 1030 + if (!vector) return 1031 + if (vector.length !== MEMORY_EMBEDDING_DIMENSIONS) { 1032 + throw new Error(`prototype embedding dimension mismatch: got ${vector.length}, expected ${MEMORY_EMBEDDING_DIMENSIONS}`) 1033 + } 1034 + upsertVector.run(BigInt(prototype.id), vectorParam(vector)) 1035 + upsertPrototype.run( 1036 + prototype.id, 1037 + prototype.name, 1038 + prototype.category, 1039 + EMBEDDING_MODEL, 1040 + MEMORY_EMBEDDING_DIMENSIONS, 1041 + prototype.hash, 1042 + ) 1043 + }) 1044 + })() 803 1045 } 804 1046 805 1047 function senderHandles(profile: MemorySearchProfile): string[] { ··· 820 1062 return out 821 1063 } 822 1064 1065 + function targetPersonHandles(profile: MemorySearchProfile): string[] { 1066 + return profile.bodyPeople.length > 0 ? profile.bodyPeople : allPersonHandles(profile) 1067 + } 1068 + 823 1069 function hitMatchesHandle(hit: MemoryHit, handle: string): boolean { 824 1070 const titleHaystack = `${hit.documentTitle} ${hit.title} ${hit.headingPath ?? ""} ${basenameWithoutExt(hit.path)}`.toLowerCase() 825 1071 const pathHaystack = hit.path.toLowerCase() 826 1072 return titleHaystack.includes(handle) || pathHaystack.includes(`/${handle}.md`) 1073 + } 1074 + 1075 + function hitMentionsHandle(hit: MemoryHit, handle: string): boolean { 1076 + return hitMatchesHandle(hit, handle) || hitSearchHaystack(hit).includes(handle) 827 1077 } 828 1078 829 1079 function scoreMemoryHit(hit: MemoryHit, profile: MemorySearchProfile): number { ··· 856 1106 } 857 1107 } 858 1108 1109 + if (profile.bodyPeople.length > 0) { 1110 + const targetHandles = targetPersonHandles(profile) 1111 + const matchesTarget = targetHandles.some((handle) => hitMatchesHandle(hit, handle)) 1112 + if (matchesTarget) { 1113 + score -= 4 1114 + } else if (hit.kind === "people") { 1115 + score += 3 1116 + } else if (hit.kind === "core" && !profileHasCoreIntent(profile)) { 1117 + score += 3.5 1118 + } 1119 + } 1120 + 859 1121 return score 860 1122 } 861 1123 ··· 915 1177 return false 916 1178 } 917 1179 918 - function searchMemory( 1180 + function profileHasExplicitRecallIntent(profile: MemorySearchProfile): boolean { 1181 + if (profile.bodyPeople.length > 0) return true 1182 + if (profile.eventQuery) return true 1183 + if (/\b(who is|who's|tell me about|remember|recall|what happened|what do i know|context)\b/.test(profile.normalized)) { 1184 + return true 1185 + } 1186 + return false 1187 + } 1188 + 1189 + function profileHasCoreIntent(profile: MemorySearchProfile): boolean { 1190 + return /\b(core|identity|system|environment|lesson|rule|instruction|workflow|tool|config|memory)\b/.test(profile.normalized) 1191 + } 1192 + 1193 + async function semanticQuerySignal(memoryQuery: string): Promise<SemanticQuerySignal | null> { 1194 + if (!isVecAvailable() || !embeddingsConfigured() || EMBEDDING_DIMENSIONS !== MEMORY_EMBEDDING_DIMENSIONS) return null 1195 + const [vector] = await embedTexts([memoryQuery]) 1196 + if (!vector || vector.length !== MEMORY_EMBEDDING_DIMENSIONS) return null 1197 + 1198 + const rows = getDb() 1199 + .prepare(` 1200 + select p.category as category, v.distance as distance 1201 + from memory_prototype_vec v 1202 + join memory_embedding_prototypes p on p.id = v.rowid 1203 + where v.embedding match ? 1204 + and k = ? 1205 + order by v.distance 1206 + `) 1207 + .all(vectorParam(vector), 8) as Array<{ category: string; distance: number }> 1208 + 1209 + let chatterSimilarity: number | null = null 1210 + let recallIntentSimilarity: number | null = null 1211 + for (const row of rows) { 1212 + const similarity = 1 - row.distance 1213 + if (row.category === "chatter") chatterSimilarity = Math.max(chatterSimilarity ?? -Infinity, similarity) 1214 + if (row.category === "recall_intent") { 1215 + recallIntentSimilarity = Math.max(recallIntentSimilarity ?? -Infinity, similarity) 1216 + } 1217 + } 1218 + 1219 + return { 1220 + vector, 1221 + chatterSimilarity, 1222 + recallIntentSimilarity, 1223 + } 1224 + } 1225 + 1226 + function shouldSkipForSemanticChatter(profile: MemorySearchProfile, signal: SemanticQuerySignal | null): boolean { 1227 + if (!signal) return false 1228 + if (profileHasExplicitRecallIntent(profile)) return false 1229 + if (profile.bodyTokens.length > 5) return false 1230 + const chatter = signal.chatterSimilarity ?? 0 1231 + const recallIntent = signal.recallIntentSimilarity ?? 0 1232 + return chatter >= MEMORY_CHATTER_SIMILARITY_THRESHOLD && recallIntent < MEMORY_RECALL_INTENT_SIMILARITY_THRESHOLD 1233 + } 1234 + 1235 + function semanticDistancesForQuery(vector: number[], limit: number): Map<number, number> { 1236 + if (!isVecAvailable()) return new Map() 1237 + const rows = getDb() 1238 + .prepare(` 1239 + select rowid as chunkId, distance 1240 + from memory_chunk_vec 1241 + where embedding match ? 1242 + and k = ? 1243 + order by distance 1244 + `) 1245 + .all(vectorParam(vector), limit) as Array<{ chunkId: number; distance: number }> 1246 + return new Map(rows.map((row) => [row.chunkId, row.distance])) 1247 + } 1248 + 1249 + function exactPersonHits(profile: MemorySearchProfile): MemoryHit[] { 1250 + if (profile.bodyPeople.length === 0) return [] 1251 + const db = getDb() 1252 + const hits: MemoryHit[] = [] 1253 + const seen = new Set<number>() 1254 + const stmt = db.prepare(` 1255 + select 1256 + c.id as chunkId, 1257 + d.path as path, 1258 + d.kind as kind, 1259 + d.title as documentTitle, 1260 + c.title as title, 1261 + c.heading_path as headingPath, 1262 + c.chunk_text as text, 1263 + -10.0 as rank 1264 + from memory_chunks c 1265 + join memory_documents d on d.id = c.document_id 1266 + where d.kind = 'people' 1267 + and lower(d.path) like ? 1268 + order by c.chunk_index 1269 + `) 1270 + 1271 + for (const handle of profile.bodyPeople) { 1272 + const rows = stmt.all(`%/people/${handle.toLowerCase()}.md`) as MemoryHit[] 1273 + for (const row of rows) { 1274 + if (seen.has(row.chunkId)) continue 1275 + seen.add(row.chunkId) 1276 + hits.push(row) 1277 + } 1278 + } 1279 + return hits 1280 + } 1281 + 1282 + function scoreMemoryHitWithSemantic(hit: MemoryHit, profile: MemorySearchProfile): number { 1283 + let score = scoreMemoryHit(hit, profile) 1284 + const coreIntent = profileHasCoreIntent(profile) 1285 + 1286 + if (hit.semanticSimilarity !== undefined) { 1287 + score -= hit.semanticSimilarity * 2.5 1288 + if (hit.kind === "core" && !coreIntent && hit.semanticSimilarity < MEMORY_SEMANTIC_STRONG_SIMILARITY) { 1289 + score += 1.25 1290 + } 1291 + } else { 1292 + score += 0.75 1293 + if (hit.kind === "core" && !coreIntent) score += 1.25 1294 + } 1295 + 1296 + if (hit.kind === "core" && !coreIntent) score += 0.75 1297 + return score 1298 + } 1299 + 1300 + async function searchMemory( 919 1301 profile: MemorySearchProfile, 920 1302 cooldowns: Record<number, number>, 921 1303 currentTurn: number, 922 1304 limit: number, 923 - ): MemoryHit[] { 1305 + semanticSignal: SemanticQuerySignal | null = null, 1306 + ): Promise<MemoryHit[]> { 924 1307 const db = getDb() 925 1308 const query = buildSearchQuery(profile) 926 1309 if (!query) return [] ··· 944 1327 `) 945 1328 .all(query, Math.max(limit * 8, limit)) as MemoryHit[] 946 1329 947 - const ordered = rows.sort((a, b) => scoreMemoryHit(a, profile) - scoreMemoryHit(b, profile)) 1330 + for (const hit of exactPersonHits(profile)) { 1331 + if (rows.some((row) => row.chunkId === hit.chunkId)) continue 1332 + rows.push(hit) 1333 + } 1334 + 1335 + if (semanticSignal) { 1336 + const distances = semanticDistancesForQuery(semanticSignal.vector, Math.max(limit * 16, 64)) 1337 + for (const row of rows) { 1338 + const distance = distances.get(row.chunkId) 1339 + if (distance === undefined) continue 1340 + row.semanticDistance = distance 1341 + row.semanticSimilarity = 1 - distance 1342 + } 1343 + } 1344 + 1345 + const ordered = rows.sort((a, b) => scoreMemoryHitWithSemantic(a, profile) - scoreMemoryHitWithSemantic(b, profile)) 948 1346 const pool = 949 1347 profile.personQuery && !profile.eventQuery 950 1348 ? (() => { ··· 962 1360 const seenChunkIds = new Set<number>() 963 1361 const seenPaths = new Set<string>() 964 1362 965 - const handles = allPersonHandles(profile) 1363 + const handles = targetPersonHandles(profile) 1364 + const exactTargetAvailable = 1365 + profile.bodyPeople.length > 0 && pool.some((row) => handles.some((handle) => hitMatchesHandle(row, handle))) 966 1366 if (handles.length >= 2) { 967 1367 for (const handle of handles) { 968 1368 if (deduped.length >= limit) break ··· 984 1384 if (deduped.length >= limit) break 985 1385 if (seenChunkIds.has(row.chunkId)) continue 986 1386 if (isCoolingDown(row, cooldowns, currentTurn)) continue 1387 + const exactTargetMatch = profile.bodyPeople.length > 0 && handles.some((handle) => hitMatchesHandle(row, handle)) 1388 + if (exactTargetAvailable && !exactTargetMatch && row.kind !== "journal") { 1389 + continue 1390 + } 1391 + if (profile.bodyPeople.length > 0 && !profile.bodyPeople.some((handle) => hitMentionsHandle(row, handle))) { 1392 + continue 1393 + } 1394 + if ( 1395 + semanticSignal && 1396 + row.kind === "core" && 1397 + !profileHasCoreIntent(profile) && 1398 + (row.semanticSimilarity ?? 0) < MEMORY_SEMANTIC_MIN_SIMILARITY 1399 + ) { 1400 + continue 1401 + } 987 1402 seenChunkIds.add(row.chunkId) 988 1403 deduped.push(row) 989 1404 } ··· 1051 1466 return { messages: conversation, recalledChunkIds: [] } 1052 1467 } 1053 1468 1469 + let semanticSignal: SemanticQuerySignal | null = null 1470 + try { 1471 + semanticSignal = await semanticQuerySignal(memoryQuery) 1472 + } catch (err: any) { 1473 + console.warn(`[memory] semantic query failed: ${err?.message ?? String(err)}`) 1474 + } 1475 + 1476 + if (shouldSkipForSemanticChatter(profile, semanticSignal)) { 1477 + console.log( 1478 + `[memory] skipped query=${JSON.stringify(trimForPrompt(normalizeText(memoryQuery), 120))} sender=${profile.sender ?? "-"} reason=semantic-chatter chatter=${semanticSignal?.chatterSimilarity?.toFixed(3) ?? "-"} recallIntent=${semanticSignal?.recallIntentSimilarity?.toFixed(3) ?? "-"}`, 1479 + ) 1480 + return { messages: conversation, recalledChunkIds: [] } 1481 + } 1482 + 1054 1483 const personCount = allPersonHandles(profile).length 1055 1484 const recallLimit = Math.min( 1056 1485 MEMORY_RECALL_MAX_CHUNKS_HARD_CAP, 1057 1486 personCount >= 2 ? MEMORY_RECALL_MAX_CHUNKS + (personCount - 1) : MEMORY_RECALL_MAX_CHUNKS, 1058 1487 ) 1059 - const hits = searchMemory(profile, cooldowns, currentTurn, recallLimit) 1488 + const hits = await searchMemory(profile, cooldowns, currentTurn, recallLimit, semanticSignal) 1060 1489 const aliasInfo = profile.senderAliases.length > 0 ? ` aliases=${profile.senderAliases.join(",")}` : "" 1061 1490 const peopleInfo = profile.bodyPeople.length > 0 ? ` people=${profile.bodyPeople.join(",")}` : "" 1062 1491 const debugTag = `sender=${profile.sender ?? "-"}${aliasInfo}${peopleInfo} personQuery=${profile.personQuery} eventQuery=${profile.eventQuery}` ··· 1129 1558 const profile = await buildSearchProfile({ sender: null, source: null, body: rawQuery }) 1130 1559 if (profile.tokens.length === 0) return [] 1131 1560 1132 - const results = searchMemory(profile, {}, Number.POSITIVE_INFINITY, Math.max(1, Math.min(limit, 10))).map(toMemorySearchResult) 1561 + let semanticSignal: SemanticQuerySignal | null = null 1562 + try { 1563 + semanticSignal = await semanticQuerySignal(rawQuery) 1564 + } catch { 1565 + semanticSignal = null 1566 + } 1567 + 1568 + const results = (await searchMemory( 1569 + profile, 1570 + {}, 1571 + Number.POSITIVE_INFINITY, 1572 + Math.max(1, Math.min(limit, 10)), 1573 + semanticSignal, 1574 + )).map(toMemorySearchResult) 1133 1575 recordMetric({ 1134 1576 type: "memory", 1135 1577 query: rawQuery,