From 45bbdc50f3f26c837dbd89361b1170381e08fe46 Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:12:51 +0300 Subject: [PATCH] feat(memory): 5 TS tools with auto-setup and fallback (#101) * feat(memory): memory-save tool with auto-setup * feat(memory): memory-search with keyword and semantic fallback * feat(memory): memory-list and memory-access tools * feat(memory): memory-doctor diagnostic tool * docs(handoff): add handoff and ADR for ts memory tools * docs(handoff): set PR number * docs(handoff): finalize pr-101 frontmatter and pending * docs(project-map): add 5 memory TS tools after PR#101 * refactor(memory): extract shared helpers to _memory-shared.ts * refactor(memory): split oversized execute functions * fix(memory): handle git checkout and catch blocks --------- Co-authored-by: opencode-agent --- .opencode/package-lock.json | 974 +++++++++++++++++++ .opencode/package.json | 7 +- .opencode/tools/_memory-shared.ts | 137 +++ .opencode/tools/memory-access.ts | 66 ++ .opencode/tools/memory-doctor.ts | 128 +++ .opencode/tools/memory-list.ts | 65 ++ .opencode/tools/memory-save.ts | 119 +++ .opencode/tools/memory-search.ts | 335 +++++++ docs/decisions/043-pr-101-ts-memory-tools.md | 39 + docs/handoff/pr-101-ts-memory-tools.md | 37 + docs/project-map/README.md | 7 +- 11 files changed, 1912 insertions(+), 2 deletions(-) create mode 100644 .opencode/package-lock.json create mode 100644 .opencode/tools/_memory-shared.ts create mode 100644 .opencode/tools/memory-access.ts create mode 100644 .opencode/tools/memory-doctor.ts create mode 100644 .opencode/tools/memory-list.ts create mode 100644 .opencode/tools/memory-save.ts create mode 100644 .opencode/tools/memory-search.ts create mode 100644 docs/decisions/043-pr-101-ts-memory-tools.md create mode 100644 docs/handoff/pr-101-ts-memory-tools.md diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..7643a33 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,974 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.18.3", + "@vscode/ripgrep": "^1.18.0" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^7.0.2" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.3.tgz", + "integrity": "sha512-hAm/hZkSCsMSNHVy9DKmFtZAve/qYoIWpduNPcvXnnKK7NMlJEOQitA6CQC67Ym5DFKypDW1TC9YO6S1WdGtpg==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.3", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.3", + "@opentui/keymap": ">=0.4.3", + "@opentui/solid": ">=0.4.3" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.3.tgz", + "integrity": "sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vscode/ripgrep": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", + "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==", + "license": "MIT", + "optionalDependencies": { + "@vscode/ripgrep-darwin-arm64": "1.18.0", + "@vscode/ripgrep-darwin-x64": "1.18.0", + "@vscode/ripgrep-linux-arm": "1.18.0", + "@vscode/ripgrep-linux-arm64": "1.18.0", + "@vscode/ripgrep-linux-ia32": "1.18.0", + "@vscode/ripgrep-linux-ppc64": "1.18.0", + "@vscode/ripgrep-linux-riscv64": "1.18.0", + "@vscode/ripgrep-linux-s390x": "1.18.0", + "@vscode/ripgrep-linux-x64": "1.18.0", + "@vscode/ripgrep-win32-arm64": "1.18.0", + "@vscode/ripgrep-win32-ia32": "1.18.0", + "@vscode/ripgrep-win32-x64": "1.18.0" + } + }, + "node_modules/@vscode/ripgrep-darwin-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz", + "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-darwin-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz", + "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz", + "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz", + "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz", + "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ppc64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz", + "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-riscv64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz", + "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-s390x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz", + "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz", + "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-win32-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz", + "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz", + "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", + "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.opencode/package.json b/.opencode/package.json index 49cae23..94db0ab 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -3,6 +3,11 @@ "test": "bun test config/plugins/" }, "dependencies": { - "@opencode-ai/plugin": "1.18.3" + "@opencode-ai/plugin": "1.18.3", + "@vscode/ripgrep": "^1.18.0" + }, + "devDependencies": { + "@types/node": "^26.1.1", + "typescript": "^7.0.2" } } diff --git a/.opencode/tools/_memory-shared.ts b/.opencode/tools/_memory-shared.ts new file mode 100644 index 0000000..f3c891e --- /dev/null +++ b/.opencode/tools/_memory-shared.ts @@ -0,0 +1,137 @@ +import { spawnSync } from "child_process" +import { createRequire } from "node:module" +import fs from "fs" +import os from "os" +import path from "path" + +const require2 = createRequire(import.meta.url) + +/** + * Resolve the memory directory from OPENCODE_MEMORY_DIR env var or fall back + * to the default ~/.local/share/opencode/opencode-memory. Identical behaviour + * across all 5 memory tools — previously duplicated 5×. + */ +export function resolveMemoryDir(): string { + const env = process.env.OPENCODE_MEMORY_DIR + if (env && env.length > 0) return path.normalize(env) + return path.join(os.homedir(), ".local/share/opencode/opencode-memory") +} + +/** + * Memory categories (also the directories auto-created by memory-save). + * Previously duplicated in memory-save and memory-list. + */ +export const CATEGORIES = [ + "preferences", + "repos", + "technical", + "people", + "workflows", + "snippets", + "notes", +] + +/** + * Frontmatter delimiter regex (flat YAML between --- fences). Previously + * duplicated in memory-search, memory-list, memory-access. + */ +export const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/ + +export interface FrontmatterMeta { + title?: string + summary?: string + importance?: string + updated?: string + tags?: string[] + related?: string[] + access_count?: number +} + +/** + * Parse flat frontmatter (regex per line, NOT real YAML — see memory note + * "Frontmatter parser is regex, not YAML"). Returns typed meta; callers + * ignore fields they don't use. Previously duplicated in memory-search (full) + * and memory-list (partial) — unified to the full version. + */ +export function parseFrontmatter(content: string): FrontmatterMeta { + const match = content.match(FRONTMATTER_RE) + if (!match) return {} + const yaml = match[1] + const meta: FrontmatterMeta = {} + for (const line of yaml.split("\n")) { + const kv = line.match(/^(\w+):\s*(.+)$/) + if (!kv) continue + const [, key, rawValue] = kv + const value = rawValue.trim() + if (key === "title") meta.title = value + else if (key === "summary") meta.summary = value + else if (key === "importance") meta.importance = value + else if (key === "updated") meta.updated = value + else if (key === "access_count") { + const parsed = parseInt(value, 10) + meta.access_count = Number.isFinite(parsed) ? parsed : 0 + } else if (key === "tags" || key === "related") { + const arr = value.match(/\[([^\]]*)\]/) + if (arr && arr[1].trim()) { + meta[key] = arr[1].split(",").map((t) => t.trim()).filter(Boolean) + } else { + meta[key] = [] + } + } + } + return meta +} + +/** + * Resolve the ripgrep binary path. Checks @vscode/ripgrep npm package first + * (platform-specific binary). When `allowSystemFallback` is true (memory-search), + * falls back to a system `rg` on $PATH. When false (memory-doctor), returns + * null if the npm package is unavailable so the doctor can report the two + * cases separately. Previously duplicated with divergent behaviour. + */ +export function resolveRgBinary(opts?: { allowSystemFallback?: boolean }): string | null { + try { + const mod = require2("@vscode/ripgrep") + if (typeof mod.rgPath === "string" && mod.rgPath.length > 0 && fs.existsSync(mod.rgPath)) { + return mod.rgPath + } + } catch (e) { + process.stderr.write(`[memory] @vscode/ripgrep require failed: ${(e as Error).message}\n`) + } + if (opts?.allowSystemFallback) { + const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) + if (sys.status === 0) return "rg" + } + return null +} + +/** + * Recursively collect absolute paths of *.md files under `dir`, skipping + * dotfiles, .git/, .rag/, and INDEX.md. Previously duplicated in memory-list + * (collecting relative paths) and memory-doctor (counting only); unified to + * return absolute paths — callers convert with path.relative() as needed. + */ +export function walkMd(dir: string): string[] { + const out: string[] = [] + const walk = (d: string) => { + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(d, { withFileTypes: true }) + } catch (e) { + process.stderr.write(`[memory] walkMd readdir failed on ${d}: ${(e as Error).message}\n`) + return + } + for (const e of entries) { + if (e.name.startsWith(".")) continue + const full = path.join(d, e.name) + if (e.isDirectory()) { + if (e.name === ".git" || e.name === ".rag") continue + walk(full) + } else if (e.isFile() && e.name.endsWith(".md") && e.name !== "INDEX.md") { + out.push(full) + } + } + } + walk(dir) + return out +} \ No newline at end of file diff --git a/.opencode/tools/memory-access.ts b/.opencode/tools/memory-access.ts new file mode 100644 index 0000000..26a4fb0 --- /dev/null +++ b/.opencode/tools/memory-access.ts @@ -0,0 +1,66 @@ +import fs from "fs" +import path from "path" +import { tool } from "@opencode-ai/plugin" +import { FRONTMATTER_RE, resolveMemoryDir } from "./_memory-shared" + +function todayISO(): string { + return new Date().toISOString().slice(0, 10) +} + +function bumpAccessFields(yaml: string, dateStr: string): { yaml: string; newCount: number } { + const countMatch = yaml.match(/^access_count:\s*(\d+)/m) + const currentCount = countMatch ? parseInt(countMatch[1], 10) : 0 + const newCount = currentCount + 1 + let updated = yaml + if (updated.match(/^last_accessed:/m)) { + updated = updated.replace(/^last_accessed:.*$/m, `last_accessed: ${dateStr}`) + } else { + updated += `\nlast_accessed: ${dateStr}` + } + if (updated.match(/^access_count:/m)) { + updated = updated.replace(/^access_count:.*$/m, `access_count: ${newCount}`) + } else { + updated += `\naccess_count: ${newCount}` + } + return { yaml: updated, newCount } +} + +export default tool({ + description: + "Record that a memory file was accessed (read and used). Updates last_accessed date and increments access_count in frontmatter. " + + "Call this AFTER reading a memory file that you actually used to inform your work — not for casual browsing. " + + "This helps the memory system track which memories are actively useful vs. stale. " + + "Atomic write (tmp + rename). Does NOT commit — the next memory_save will sync the change.", + args: { + path: tool.schema + .string() + .describe("Relative path within the memory directory (e.g. 'technical/build-tooling.md')"), + }, + async execute(args) { + const memoryDir = resolveMemoryDir() + const filePath = path.join(memoryDir, args.path) + if (!fs.existsSync(filePath)) return `Could not update ${args.path} (file not found)` + let content: string + try { + content = fs.readFileSync(filePath, "utf-8") + } catch (e) { + process.stderr.write(`[memory-access] read failed ${args.path}: ${(e as Error).message}\n`) + return `Could not update ${args.path}` + } + const fmMatch = content.match(FRONTMATTER_RE) + if (!fmMatch) return `No frontmatter in ${args.path} — skipped` + const yaml = fmMatch[1] + const body = fmMatch[2] + const { yaml: updatedYaml, newCount } = bumpAccessFields(yaml, todayISO()) + const newContent = `---\n${updatedYaml}\n---\n${body}` + try { + const tmp = filePath + ".tmp" + fs.writeFileSync(tmp, newContent, "utf-8") + fs.renameSync(tmp, filePath) + } catch (e) { + process.stderr.write(`[memory-access] write failed ${args.path}: ${(e as Error).message}\n`) + return `Could not update ${args.path}` + } + return `Accessed: ${args.path}, count=${newCount}` + }, +}) \ No newline at end of file diff --git a/.opencode/tools/memory-doctor.ts b/.opencode/tools/memory-doctor.ts new file mode 100644 index 0000000..49f4f31 --- /dev/null +++ b/.opencode/tools/memory-doctor.ts @@ -0,0 +1,128 @@ +import { spawnSync } from "child_process" +import fs from "fs" +import path from "path" +import { tool } from "@opencode-ai/plugin" +import { resolveMemoryDir, resolveRgBinary, walkMd } from "./_memory-shared" + +function mark(ok: boolean): string { + return ok ? "✅" : "❌" +} + +function checkRipgrep(rgBin: string | null): string[] { + const lines: string[] = [] + if (rgBin) { + const r = spawnSync(rgBin, ["--version"], { encoding: "utf-8" }) + const ver = (r.stdout || "").split("\n")[0].trim() + lines.push(`- ${mark(r.status === 0)} ripgrep (keyword search): ${rgBin}${ver ? ` — ${ver}` : ""}`) + } else { + const sys = spawnSync("rg", ["--version"], { encoding: "utf-8" }) + if (sys.status === 0) { + lines.push(`- ${mark(true)} ripgrep (keyword search): system rg on $PATH`) + } else { + lines.push(`- ❌ ripgrep (keyword search): NOT resolvable — install @vscode/ripgrep or system rg`) + } + } + return lines +} + +function checkPythonMemory(worktree: string): { lines: string[]; ok: boolean } { + const pyR = spawnSync("python3", ["-c", "import src.memory; print('ok')"], { + encoding: "utf-8", + cwd: worktree, + }) + const lines = [ + `- ${mark(pyR.status === 0)} Python src.memory importable (semantic search): ${ + pyR.status === 0 ? "yes" : `no — ${(pyR.stderr || pyR.stdout || "").trim().split("\n").pop() || ""}` + }`, + ] + return { lines, ok: pyR.status === 0 } +} + +function checkEnvVars(): string[] { + return [ + `- ${mark(!!process.env.OPENAI_BASE_URL)} OPENAI_BASE_URL set (embeddings endpoint): ${ + process.env.OPENAI_BASE_URL ? "✓" : "not set — semantic search disabled, keyword still works" + }`, + `- ${mark(!!process.env.OPENAI_API_KEY)} OPENAI_API_KEY set (embeddings auth): ${ + process.env.OPENAI_API_KEY ? "✓" : "not set" + }`, + `- ${mark(!!process.env.OPENCODE_MEMORY_REMOTE)} OPENCODE_MEMORY_REMOTE set (auto-push to git): ${ + process.env.OPENCODE_MEMORY_REMOTE ? "✓" : "not set — auto-push disabled" + }`, + ] +} + +function checkIndex(memoryDir: string): string[] { + const lines: string[] = [] + lines.push( + `- ${mark(fs.existsSync(memoryDir))} OPENCODE_MEMORY_DIR exists: ${memoryDir}${ + fs.existsSync(memoryDir) ? "" : "— run memory_save to auto-setup" + }` + ) + if (!fs.existsSync(memoryDir)) return lines + + const gitDir = path.join(memoryDir, ".git") + lines.push( + `- ${mark(fs.existsSync(gitDir))} memory dir is a git repo: ${ + fs.existsSync(gitDir) ? "yes" : "no — will be init on first memory_save" + }` + ) + const indexJson = path.join(memoryDir, ".rag", "index.json") + lines.push( + `- ${mark(fs.existsSync(indexJson))} RAG index exists: ${ + fs.existsSync(indexJson) + ? path.relative(memoryDir, indexJson) + : "not built — first memory_save with OPENAI_BASE_URL will trigger reindex" + }` + ) + if (fs.existsSync(indexJson)) { + try { + const stat = fs.statSync(indexJson) + const sizeMb = (stat.size / 1024 / 1024).toFixed(1) + lines.push(` (size: ${sizeMb} MB, modified: ${stat.mtime.toISOString().slice(0, 10)})`) + } catch (e) { + process.stderr.write(`[memory-doctor] stat index.json failed: ${(e as Error).message}\n`) + } + } + try { + const mdCount = walkMd(memoryDir).length + lines.push(`- ℹ️ memory files (.md): ${mdCount}`) + } catch (e) { + process.stderr.write(`[memory-doctor] walkMd failed: ${(e as Error).message}\n`) + } + return lines +} + +function formatDoctorReport(rgBin: string | null, pyOk: boolean, memoryDir: string): string { + const indexJson = path.join(memoryDir, ".rag", "index.json") + const allGreen = + rgBin !== null && pyOk && fs.existsSync(memoryDir) && fs.existsSync(indexJson) + if (allGreen) return "All green — both keyword and semantic search available." + if (rgBin !== null) return "Keyword search works. Semantic search unavailable — see ❌ items above." + return "ripgrep missing — keyword search will return no results. Install @vscode/ripgrep." +} + +export default tool({ + description: + "Read-only diagnostic for the memory subsystem. Reports: ripgrep (keyword) availability, Python src.memory importability, " + + "env vars (OPENAI_BASE_URL, OPENAI_API_KEY, OPENCODE_MEMORY_REMOTE), memory dir existence, RAG index existence. " + + "Does NOT modify anything. Run when memory_search/memory_save misbehave or to verify setup.", + args: {}, + async execute(_args, context) { + const memoryDir = resolveMemoryDir() + const lines: string[] = ["## Memory Doctor\n"] + + const rgBin = resolveRgBinary() + lines.push(...checkRipgrep(rgBin)) + + const py = checkPythonMemory(context.worktree) + lines.push(...py.lines) + + lines.push(...checkEnvVars()) + lines.push(...checkIndex(memoryDir)) + + lines.push("") + lines.push(formatDoctorReport(rgBin, py.ok, memoryDir)) + return lines.join("\n") + }, +}) \ No newline at end of file diff --git a/.opencode/tools/memory-list.ts b/.opencode/tools/memory-list.ts new file mode 100644 index 0000000..607b8e4 --- /dev/null +++ b/.opencode/tools/memory-list.ts @@ -0,0 +1,65 @@ +import fs from "fs" +import path from "path" +import { tool } from "@opencode-ai/plugin" +import { CATEGORIES, parseFrontmatter, resolveMemoryDir, walkMd } from "./_memory-shared" + +export default tool({ + description: + "Browse memories in the memory directory. Without a category, lists all categories with file counts. " + + "With a category, lists files in that category with their summaries (parsed from frontmatter). " + + "Pure TS, no subprocess. Categories: preferences, repos, technical, people, workflows, snippets, notes.", + args: { + category: tool.schema + .string() + .optional() + .describe("Category to list: preferences, repos, technical, people, workflows, snippets, notes"), + }, + async execute(args) { + const memoryDir = resolveMemoryDir() + if (!args.category) { + const lines = ["## Memory Categories\n"] + for (const cat of CATEGORIES) { + const catDir = path.join(memoryDir, cat) + const files = fs.existsSync(catDir) ? walkMd(catDir) : [] + lines.push(`- **${cat}/** (${files.length} files)`) + } + return lines.join("\n") + } + const catDir = path.join(memoryDir, args.category) + if (!fs.existsSync(catDir)) return `Category not found: ${args.category}` + const absFiles = walkMd(catDir) + if (absFiles.length === 0) return `No memories in category: ${args.category}` + + interface Row { + path: string + title: string + summary: string + importance: string + updated: string + } + const rows: Row[] = [] + for (const abs of absFiles) { + try { + const content = fs.readFileSync(abs, "utf-8") + const meta = parseFrontmatter(content) + const rel = path.relative(catDir, abs) + rows.push({ + path: `${args.category}/${rel}`, + title: meta.title || rel.replace(/\.md$/, ""), + summary: meta.summary || "", + importance: meta.importance || "medium", + updated: meta.updated || "", + }) + } catch (e) { + process.stderr.write(`[memory-list] read failed ${abs}: ${(e as Error).message}\n`) + } + } + rows.sort((a, b) => (b.updated || "").localeCompare(a.updated || "")) + const lines = [`## ${args.category}/ (${rows.length} files)\n`] + for (const f of rows) { + lines.push(`- **${f.path}** [${f.importance}] ${f.updated ? `(${f.updated})` : ""}`) + if (f.summary) lines.push(` ${f.summary}`) + } + return lines.join("\n") + }, +}) \ No newline at end of file diff --git a/.opencode/tools/memory-save.ts b/.opencode/tools/memory-save.ts new file mode 100644 index 0000000..0681a78 --- /dev/null +++ b/.opencode/tools/memory-save.ts @@ -0,0 +1,119 @@ +import { spawn, spawnSync } from "child_process" +import fs from "fs" +import path from "path" +import { tool } from "@opencode-ai/plugin" +import { CATEGORIES, resolveMemoryDir } from "./_memory-shared" + +const HOOK_BODY = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n" + +function run(cmd: string, args: string[], opts?: { cwd?: string }) { + return spawnSync(cmd, args, { encoding: "utf-8", ...opts }) +} + +function isEmptyDir(dir: string): boolean { + if (!fs.existsSync(dir)) return true + return fs.readdirSync(dir).filter((n) => !n.startsWith(".")).length === 0 +} + +function autoSetup(memoryDir: string, remote: string | undefined): string[] { + const steps: string[] = [] + if (!fs.existsSync(memoryDir)) { + fs.mkdirSync(memoryDir, { recursive: true }) + steps.push(`created directory: ${memoryDir}`) + } + if (!fs.existsSync(path.join(memoryDir, ".git"))) { + if (remote && isEmptyDir(memoryDir)) { + const r = run("git", ["clone", "--origin", "origin", remote, memoryDir]) + if (r.status !== 0) { + run("git", ["init", memoryDir]) + steps.push(`git init (clone failed: ${(r.stderr || "").trim()})`) + } else { + const co = run("git", ["-C", memoryDir, "checkout", "master"]) + if (co.status !== 0) { + process.stderr.write( + `[memory-save] git checkout master failed (exit ${co.status}): ${(co.stderr || co.stdout || "").trim()}\n` + ) + steps.push("cloned remote (checkout master failed — check stderr)") + } else { + steps.push(`cloned remote: ${remote}`) + } + } + } else { + run("git", ["init", memoryDir]) + steps.push("git init") + } + } + if (remote) { + const hookPath = path.join(memoryDir, ".git", "hooks", "post-commit") + let needHook = true + if (fs.existsSync(hookPath)) { + if (fs.readFileSync(hookPath, "utf-8") === HOOK_BODY) needHook = false + } + if (needHook) { + fs.mkdirSync(path.dirname(hookPath), { recursive: true }) + fs.writeFileSync(hookPath, HOOK_BODY, { mode: 0o755 }) + steps.push("installed post-commit hook (auto-push)") + } + const cur = run("git", ["-C", memoryDir, "remote", "get-url", "origin"]) + if (cur.status !== 0 || (cur.stdout || "").trim() !== remote) { + run("git", ["-C", memoryDir, "remote", "set-url", "origin", remote]) + steps.push(`set remote origin: ${remote}`) + } + } + for (const cat of CATEGORIES) { + const catDir = path.join(memoryDir, cat) + if (!fs.existsSync(catDir)) fs.mkdirSync(catDir, { recursive: true }) + } + return steps +} + +export default tool({ + description: + "Commit and re-index all pending memory changes. Call AFTER using Write/Edit tools on ~/.local/share/opencode/opencode-memory/{category}/{filename}.md. " + + "Handles: auto-setup (mkdir + git init/clone + hook) on first call, git add -A, commit (message derived from changed files), async RAG re-index (fire-and-forget, log to .rag/reindex.log). " + + "WHEN TO SAVE (always save when you discover something reusable): learned non-obvious API pattern/tool quirk/workaround, discovered repo structure/conventions/gotchas, resolved tricky debugging with non-obvious root cause, learned team/ownership info. " + + "DO NOT save: one-off answers, things in public docs, context only relevant to the current task. " + + "CATEGORIES: preferences, repos, technical, people, workflows, snippets, notes. " + + "FRONTMATTER FORMAT: ---\\ntitle: ...\\ntags: [...]\\nsummary: ...\\ncreated: YYYY-MM-DD\\nupdated: YYYY-MM-DD\\nimportance: high|medium|low\\nrelated: [...]\\n---", + args: {}, + async execute(_args, context) { + const memoryDir = resolveMemoryDir() + const remote = process.env.OPENCODE_MEMORY_REMOTE + const setupSteps = autoSetup(memoryDir, remote) + + const addR = run("git", ["-C", memoryDir, "add", "-A"]) + if (addR.status !== 0) { + return `⚠️ memory-save: git add failed (exit ${addR.status}): ${addR.stderr || addR.stdout}` + } + const diffR = run("git", ["-C", memoryDir, "diff", "--name-only", "--cached"]) + if (diffR.status !== 0) { + return `⚠️ memory-save: git diff failed (exit ${diffR.status}): ${diffR.stderr || diffR.stdout}` + } + const changed = (diffR.stdout || "").trim().split("\n").filter(Boolean) + if (changed.length === 0) return "No changes to sync" + + const msg = `memory: sync ${changed.join(", ")}` + const commitR = run("git", ["-C", memoryDir, "commit", "-m", msg]) + if (commitR.status !== 0) { + return `⚠️ memory-save: git commit failed (exit ${commitR.status}): ${commitR.stderr || commitR.stdout}` + } + + if (process.env.OPENAI_BASE_URL) { + const indexDir = path.join(memoryDir, ".rag") + fs.mkdirSync(indexDir, { recursive: true }) + const child = spawn("python3", ["-m", "src.memory", "index", memoryDir, "-o", indexDir], { + cwd: context.worktree, + stdio: "ignore", + detached: true, + }) + child.unref() + } + + const parts = [`Synced: ${changed.join(", ")}`] + if (setupSteps.length > 0) parts.push(`Auto-setup: ${setupSteps.join("; ")}`) + if (!process.env.OPENAI_BASE_URL) { + parts.push("(semantic index skipped: OPENAI_BASE_URL not set — keyword search still works)") + } + return parts.join("\n") + }, +}) \ No newline at end of file diff --git a/.opencode/tools/memory-search.ts b/.opencode/tools/memory-search.ts new file mode 100644 index 0000000..1fe648b --- /dev/null +++ b/.opencode/tools/memory-search.ts @@ -0,0 +1,335 @@ +import { spawnSync } from "child_process" +import fs from "fs" +import path from "path" +import { tool } from "@opencode-ai/plugin" +import { + parseFrontmatter, + resolveMemoryDir, + resolveRgBinary, + type FrontmatterMeta, +} from "./_memory-shared" + +const STOP_WORDS = new Set(["the", "a", "an", "is", "of", "to", "in", "for", "on", "and", "or"]) + +function parseSearchTerms(query: string): string[] { + const terms: string[] = [] + const regex = /"([^"]+)"|(\S+)/g + let match + while ((match = regex.exec(query)) !== null) { + const original = match[1] || match[2] + const lower = original.toLowerCase() + if (lower.length < 2) continue + if (STOP_WORDS.has(lower)) continue + terms.push(original) + } + return terms +} + +function countTermMatches(text: string, terms: string[]): number { + if (terms.length === 0) return 0 + const lowerText = text.toLowerCase() + return terms.filter((t) => lowerText.includes(t.toLowerCase())).length +} + +interface ScoreInput { + rgMatch: boolean + ragScore?: number + termMatches: number + totalTerms: number + tags?: string[] + path: string + importance?: string + accessCount?: number + terms: string[] +} + +function scoreCandidate(input: ScoreInput): number { + let score = 0 + if (input.rgMatch) { + const termCoverage = input.totalTerms > 0 ? input.termMatches / input.totalTerms : 1 + score += 0.15 + 0.35 * termCoverage + } + if (input.ragScore) score += input.ragScore * 1.4 + if (input.rgMatch && input.ragScore) score += 0.1 + if (input.tags && input.tags.length > 0 && input.terms.length > 0) { + const lowerTags = input.tags.map((t) => t.toLowerCase()) + const tagHits = input.terms.filter((t) => + lowerTags.some((tag) => tag.includes(t.toLowerCase())) + ).length + if (tagHits > 0) score += 0.2 * (tagHits / input.terms.length) + } + const pathLower = input.path.toLowerCase().replace(/[-_/.]/g, " ") + const pathHits = input.terms.filter((t) => pathLower.includes(t.toLowerCase())).length + if (pathHits > 0 && input.terms.length > 0) { + score += 0.15 * (pathHits / input.terms.length) + } + if (input.importance === "high") score += 0.15 + else if (input.importance === "low") score -= 0.1 + if (input.accessCount && input.accessCount >= 5) score += 0.1 + else if (input.accessCount && input.accessCount >= 2) score += 0.05 + return score +} + +interface RagHit { + source: string + score: number + text: string +} + +function parseRagHits(ragText: string): RagHit[] { + if (!ragText) return [] + try { + const parsed = JSON.parse(ragText) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (hit): hit is RagHit => + hit && + typeof hit.source === "string" && + typeof hit.score === "number" && + typeof hit.text === "string" + ) + } catch (e) { + process.stderr.write(`[memory-search] parseRagHits JSON parse failed: ${(e as Error).message}\n`) + return [] + } +} + +function buildRgArgs(terms: string[]): string[] { + const args = ["-il", "--glob", "*.md", "--glob", "!.git", "--glob", "!.rag", "--glob", "!**/INDEX.md"] + for (const term of terms) args.push("-e", term) + return args +} + +function toRelPath(memoryDir: string, absPath: string): string { + return path.relative(memoryDir, path.normalize(absPath)) +} + +interface CandInfo { + rgMatch: boolean + ragScore?: number + ragText?: string +} + +function runKeywordSearch( + rgBin: string, + terms: string[], + searchDir: string, + memoryDir: string +): Map { + const resultMap = new Map() + const rgR = spawnSync(rgBin, [...buildRgArgs(terms), searchDir], { encoding: "utf-8" }) + if (rgR.status === 0) { + for (const line of (rgR.stdout || "").trim().split("\n").filter(Boolean)) { + const rel = toRelPath(memoryDir, line) + if (rel.endsWith(".md") && !rel.startsWith(".")) { + resultMap.set(rel, { rgMatch: true }) + } + } + } + return resultMap +} + +function runSemanticSearch( + query: string, + indexDir: string, + worktree: string +): RagHit[] { + if (!process.env.OPENAI_BASE_URL) return [] + if (!fs.existsSync(path.join(indexDir, "index.json"))) return [] + const r = spawnSync( + "python3", + ["-m", "src.memory", "search", query, "-i", indexDir, "-k", "15", "--json"], + { cwd: worktree, encoding: "utf-8" } + ) + if (r.status !== 0) { + if (r.stderr) process.stderr.write(`[memory-search] rag search failed (exit ${r.status}): ${r.stderr.trim()}\n`) + return [] + } + return parseRagHits(r.stdout || "") +} + +function mergeRagHits( + resultMap: Map, + ragHits: RagHit[], + category?: string +): void { + for (const hit of ragHits) { + if (hit.source.endsWith("INDEX.md")) continue + if (category && !hit.source.startsWith(category + "/")) continue + const existing = resultMap.get(hit.source) || { rgMatch: false } + if (!existing.ragScore || hit.score > existing.ragScore) { + existing.ragScore = hit.score + existing.ragText = hit.text + } + resultMap.set(hit.source, existing) + } +} + +interface ResultRow { + path: string + meta: FrontmatterMeta + score: number + termMatches: number + rgMatch: boolean + ragScore?: number + ragText?: string +} + +function scoreAndCombine( + resultMap: Map, + terms: string[], + memoryDir: string +): ResultRow[] { + const results: ResultRow[] = [] + for (const [p, info] of resultMap) { + try { + const content = fs.readFileSync(path.join(memoryDir, p), "utf-8") + const meta = parseFrontmatter(content) + const termMatches = + terms.length > 0 ? countTermMatches(content, terms) : info.rgMatch ? 1 : 0 + const score = scoreCandidate({ + rgMatch: info.rgMatch, + ragScore: info.ragScore, + termMatches, + totalTerms: terms.length, + tags: meta.tags, + path: p, + importance: meta.importance, + accessCount: meta.access_count, + terms, + }) + results.push({ + path: p, + meta, + score, + termMatches, + rgMatch: info.rgMatch, + ragScore: info.ragScore, + ragText: info.ragText, + }) + } catch (e) { + process.stderr.write(`[memory-search] score read failed ${p}: ${(e as Error).message}\n`) + } + } + results.sort((a, b) => b.score - a.score) + return results +} + +function formatResults( + results: ResultRow[], + query: string, + terms: string[], + category: string | undefined, + limit: number, + memoryDir: string +): string { + const lines = [`## Results for "${query}" (${results.length} matches)`] + if (category && results.length > 0) { + lines.push(`_Filtered to **${category}/**_`) + } + if (terms.length > 1) lines.push(`_Searching for: ${terms.join(", ")}_`) + + const filtered = results.filter((r) => r.score >= 0.2) + const topResults = filtered.length > 0 ? filtered : results.slice(0, 3) + const MAX = Math.min(limit, 7) + for (const [i, r] of topResults.slice(0, MAX).entries()) { + const isDirectHit = + r.rgMatch && r.ragScore !== undefined && r.ragScore > 0.4 && r.termMatches === terms.length + const hitLabel = isDirectHit ? " ★ DIRECT HIT" : "" + const importance = r.meta.importance || "medium" + lines.push(`${i + 1}. **${r.path}** [${importance}]${hitLabel}`) + if (r.meta.tags?.length) lines.push(` Tags: ${r.meta.tags.join(", ")}`) + if (r.meta.summary) lines.push(` ${r.meta.summary}`) + const sources: string[] = [] + if (r.rgMatch) { + const termInfo = terms.length > 1 ? ` (${r.termMatches}/${terms.length} terms)` : "" + sources.push(`keyword${termInfo}`) + } + if (r.ragScore) sources.push(`semantic: ${r.ragScore.toFixed(2)}`) + lines.push(` Match: ${sources.join(" + ")} | score: ${r.score.toFixed(2)}`) + if (r.meta.related?.length) lines.push(` Related: ${r.meta.related.join(", ")}`) + if (r.ragText) lines.push(` Preview: "...${r.ragText.slice(0, 200).trim()}..."`) + lines.push("") + } + + if (topResults.length > 0) { + lines.push(`_Read the top result: ${topResults[0].path}_`) + lines.push("") + } + + const shownPaths = new Set(topResults.slice(0, 7).map((r) => r.path)) + const relatedSuggestions: string[] = [] + for (const r of topResults.slice(0, 7)) { + for (const rel of r.meta.related || []) { + if (!shownPaths.has(rel) && !relatedSuggestions.includes(rel)) relatedSuggestions.push(rel) + } + } + const verified: string[] = [] + for (const rel of relatedSuggestions) { + try { + if (fs.existsSync(path.join(memoryDir, rel))) verified.push(rel) + } catch (e) { + process.stderr.write(`[memory-search] related exists check failed ${rel}: ${(e as Error).message}\n`) + } + } + if (verified.length > 0) { + lines.push("---") + lines.push(`**Related files** (not in results — use the Read tool):`) + for (const rel of verified) lines.push(` - ${rel}`) + lines.push("") + } + return lines.join("\n") +} + +export default tool({ + description: + "Search memories in the memory directory using both keyword (ripgrep) and semantic (Python rag) search. " + + "Multi-term queries match files containing ANY search term (OR logic); files matching more terms rank higher. " + + "Results are summaries only (path, tags, importance, short context). Use the Read tool on the memory path to get the full content. " + + "WHEN TO SEARCH (do this BEFORE starting work): starting work on any repo, using unfamiliar tool/API, encountering unfamiliar codebase, debugging a problem, looking up people/team info, before writing new memory. " + + "FALLBACK: if semantic search is unavailable (OPENAI_BASE_URL not set, Python crash, no index), keyword-only results are returned — never fails.", + args: { + query: tool.schema.string().describe("Search terms or natural language query"), + category: tool.schema + .string() + .optional() + .describe("Filter to a category: preferences, repos, technical, people, workflows, snippets, notes"), + limit: tool.schema.number().optional().describe("Max results (default 15)"), + }, + async execute(args, context) { + const memoryDir = resolveMemoryDir() + const searchDir = args.category ? path.join(memoryDir, args.category) : memoryDir + const terms = parseSearchTerms(args.query) + const rgTerms = terms.length > 0 ? terms : [args.query] + const limit = args.limit ?? 15 + const rgBin = resolveRgBinary({ allowSystemFallback: true }) + + if (!fs.existsSync(memoryDir)) { + return `No memories found for: "${args.query}" (memory directory not initialized — run memory_save to auto-setup)` + } + + let resultMap = new Map() + if (rgBin) { + resultMap = runKeywordSearch(rgBin, rgTerms, searchDir, memoryDir) + } + + const indexDir = path.join(memoryDir, ".rag") + const ragHits = runSemanticSearch(args.query, indexDir, context.worktree) + mergeRagHits(resultMap, ragHits, args.category) + + if (resultMap.size === 0 && args.category) { + if (rgBin) { + const fallback = runKeywordSearch(rgBin, rgTerms, memoryDir, memoryDir) + for (const [k, v] of fallback) resultMap.set(k, v) + } + mergeRagHits(resultMap, ragHits) + } + + if (resultMap.size === 0) { + return `No memories found for: "${args.query}"` + } + + const results = scoreAndCombine(resultMap, terms, memoryDir) + return formatResults(results, args.query, terms, args.category, limit, memoryDir) + }, +}) \ No newline at end of file diff --git a/docs/decisions/043-pr-101-ts-memory-tools.md b/docs/decisions/043-pr-101-ts-memory-tools.md new file mode 100644 index 0000000..a95b9ba --- /dev/null +++ b/docs/decisions/043-pr-101-ts-memory-tools.md @@ -0,0 +1,39 @@ +# ADR-043: 5 TS memory tools with auto-setup and progressive enhancement + +## Статус +Accepted (2026-07-26) + +## Контекст +Плагин `@mathew-cf/opencode-memory` (MCP server wrapper) не вызывается из-за бага wrapper-пути: `MEMORY_WRAPPER_PATH` в `setup-memory.sh` указывает на `@mathew-cf/rag-cli/bin/rag.js`, который не существует или несовместим (GLIBC 2.39 vs 2.36 — см. ADR-030, PR#57, PR#71). Плагин деградирует до keyword-only через grep, но wrapper-hop добавляет fragility. Нужны нативные TS tools, вызывающие Python через `spawnSync` (паттерн `pipeline-status.ts`, `commit.ts`, `memory-setup.ts`). + +Фаза 1 (PR#100) сделала embedder lazy-init (returns `None` без `OPENAI_BASE_URL`, не raises), index.py логирует reindex в `.rag/reindex.log`, search.py обрабатывает `None` от embedder → возвращает `[]`. Это разблокировало TS tools: Python backend безопасен при отсутствии env vars. + +Требования к миграции (issue #96, #94): +- 5 tools: `memory-save`, `memory-search`, `memory-list`, `memory-access`, `memory-doctor` +- Keyword search (ripgrep) работает ВСЕГДА (zero env dependency) +- Semantic search (Python + OpenRouter) как enhancement с fallback на keyword-only +- Zero-config: первый `memory-save` авто-инициализирует память (mkdir + git init/clone + hook + categories) +- Scoring портирован из плагина `scoreCandidate` (rgMatch + ragScore×1.4 + tags + importance + accessCount) +- Pure-orchestrator model (ADR-010): TS tools вызываются из главного чата, делегируют в Python через subprocess + +## Решение +5 TS tools в `.opencode/tools/`, каждый следует паттерну `tool()` из `@opencode-ai/plugin` (description + args (zod) + async execute(args, context)): + +1. **memory-save** — auto-setup + git commit + async reindex. `resolveMemoryDir()` (env или `~/.local/share/opencode/opencode-memory`). `autoSetup()`: mkdir, git init (или clone если `OPENCODE_MEMORY_REMOTE` set и dir пуста), post-commit hook (auto-push, только если remote), 7 категорий. `git add -A` + `git diff --cached --name-only` → commit с derived message `memory: sync `. Async reindex: `spawn(python3, ["-m","src.memory","index",...], {detached:true, stdio:"ignore"}) + child.unref()` (fire-and-forget). Skip semantic если `OPENAI_BASE_URL` не set. + +2. **memory-search** — ripgrep + Python semantic + scoring + fallback. `resolveRgBinary()`: `@vscode/ripgrep` (платформенный binary) → fallback system `rg`. Keyword: `rg -il --glob *.md --glob !.git --glob !.rag --glob !**/INDEX.md -e ` per term (OR). Semantic: `spawnSync("python3", ["-m","src.memory","search",...])` если `OPENAI_BASE_URL` set AND `.rag/index.json` существует. Fallback на keyword-only если Python упал/`[]`/env не set. Scoring — порт `scoreCandidate` из плагина (`dist/index.js:12822-12852`). Cross-category fallback если category filter дал 0 hits. + +3. **memory-list** — pure TS, без subprocess. Список категорий (count .md) или файлов в категории (title/summary/importance/updated из frontmatter). + +4. **memory-access** — pure TS, bump frontmatter `last_accessed`/`access_count` через regex replace (порт `bumpAccessFields` из плагина `dist/index.js:12677-12694`). Atomic write (tmp + rename). + +5. **memory-doctor** — read-only диагностика (rg, Python importability, env vars, memory dir, RAG index). + +Зависимости: `@vscode/ripgrep ^1.18.0` (keyword search, платформенный binary), devDeps `@types/node` + `typescript` (typecheck). + +## Альтернативы +- **Python MCP server** (Option A в `technical/cloud-memory-opencode-landscape.md`, `technical/python-mcp-server-replace-plugin-feasibility.md`) — отклонён: добавляет `mcp` dep (22 transitive), Dockerfile layer, embedder.py lazy-init (сделано в PR#100), naming verification. TS tools проще для opencode-only use case (reuse proven `spawnSync` pattern, no new Python deps, no Dockerfile changes). +- **Оставить плагин + починить wrapper** — отклонено: wrapper-hop (`setup-memory.sh` → JS wrapper → Python CLI) добавляет fragility, `@mathew-cf/rag-cli` требует GLIBC 2.39 (несовместим с node:20-slim на Bookworm, см. ADR-030). TS tools вызывают Python напрямую через `python3 -m src.memory`. +- **System `rg` без `@vscode/ripgrep`** — отклонён: системный `rg` может отсутствовать (Docker slim images). `@vscode/ripgrep` предоставляет платформенный binary (npm-managed, reproducible). memory-search имеет fallback на system `rg` через `spawnSync("rg", ["--version"])`. +- **Полноценный YAML parser (js-yaml) для frontmatter** — отклонён: наш формат плоский (плоские ключи + `[a, b]` для tags/related). Regex-парсер (тот же что в плагине `parseFrontmatter`) покрывает формат и не добавляет dep. +- **Conventional commit format для memory-save commits** — отклонён: memory repo не part основного репо, commit message derived из changed files (`memory: sync `), не human-curated. Тот же паттерн что в плагине `runSave`. `commit` tool (для main repo) остаётся с conventional validation. \ No newline at end of file diff --git a/docs/handoff/pr-101-ts-memory-tools.md b/docs/handoff/pr-101-ts-memory-tools.md new file mode 100644 index 0000000..b42c017 --- /dev/null +++ b/docs/handoff/pr-101-ts-memory-tools.md @@ -0,0 +1,37 @@ +--- +pr: 101 +title: feat(memory): 5 TS tools with auto-setup and fallback +--- + +## Что сделано +5 TS tools в `.opencode/tools/` заменяющие плагин `@mathew-cf/opencode-memory`. Прогрессивная модель: keyword (ripgrep) работает всегда, semantic (Python+OpenRouter) как enhancement с fallback. Zero-config: первый `memory-save` авто-инициализирует память. + +- `memory-save.ts` (~120 LOC) — auto-setup + git commit + async reindex. Определяет `memoryDir` (env или `~/.local/share/opencode/opencode-memory`). При первом вызове: `mkdir -p`, `git init` (или `git clone` если `OPENCODE_MEMORY_REMOTE` set и dir пуста), установка post-commit hook (`git push origin master 2>/dev/null || true`) при наличии remote, создание 7 категорий. Затем `git add -A` + `git diff --cached --name-only` → если пусто, return "No changes to sync". `git commit -m "memory: sync "`. Async reindex: `spawn("python3", ["-m","src.memory","index",memoryDir,"-o",.rag], {cwd: context.worktree, stdio:"ignore", detached:true}) + child.unref()` — fire-and-forget, НЕ блокирует ответ. Лог в `.rag/reindex.log` (Python из PR#100). Skip semantic если `OPENAI_BASE_URL` не set. +- `memory-search.ts` (~270 LOC) — ripgrep + Python semantic + scoring + fallback. `resolveRgBinary()`: `require2("@vscode/ripgrep").rgPath` (если установлен и существует), fallback на системный `rg`. Keyword: `spawnSync(rgBin, [...buildRgArgs(terms), searchDir])` — `--glob *.md --glob !.git --glob !.rag --glob !**/INDEX.md -e ` per term (OR logic). Semantic: если `OPENAI_BASE_URL` set AND `.rag/index.json` существует → `spawnSync("python3", ["-m","src.memory","search",query,"-i",.rag,"-k","15","--json"], {cwd: context.worktree})`. Если Python упал (status≠0) / вернул `[]` / env не set → skip semantic (fallback keyword-only). Scoring — порт `scoreCandidate()` из плагина `dist/index.js:12822-12852`: rgMatch (+0.15+0.35*termCoverage), ragScore×1.4, rgMatch+ragScore bonus +0.10, tags overlap +0.20*(tagHits/terms), path hits +0.15, importance (high +0.15, low -0.10), accessCount (≥5 +0.10, ≥2 +0.05). Cross-category fallback если category filter дал 0 hits. Output: markdown с top-7 результатами + Related files. +- `memory-list.ts` (~100 LOC) — pure TS, без subprocess. Без `category` → список 7 категорий с count .md файлов (`walkMd` рекурсивно, skip `.git`/`.rag`/`INDEX.md`). С `category` → список файлов (`{path, title, summary, importance, updated}` из frontmatter), отсортированы по `updated` descending. +- `memory-access.ts` (~70 LOC) — pure TS, bump frontmatter. Читает файл, парсит frontmatter, обновляет `last_accessed: ` и `access_count: ` (regex replace, порт `bumpAccessFields` из плагина `dist/index.js:12677-12694`). Atomic write: `writeFileSync(tmp) + renameSync(tmp,filePath)`. НЕ коммитит — следующий `memory-save` синхронизирует. +- `memory-doctor.ts` (~85 LOC) — read-only диагностика. Проверяет: `rg` (через `@vscode/ripgrep` + fallback system rg + `spawnSync --version`), Python `src.memory` importable (`python3 -c "import src.memory"`, cwd=worktree), env vars (`OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENCODE_MEMORY_REMOTE`), `OPENCODE_MEMORY_DIR` exists, memory dir is git repo, `.rag/index.json` exists (size+mtime), count .md files. Возвращает markdown с ✅/❌ по каждому пункту + summary (all-green / keyword-only / rg missing). +- `.opencode/package.json` — добавлены deps: `@vscode/ripgrep ^1.18.0` (keyword search, платформенный binary `@vscode/ripgrep-linux-x64/bin/rg`), devDeps: `@types/node ^26.1.1` (typecheck) + `typescript ^7.0.2` (typecheck). +- ADR-043 + этот handoff. + +Scoring logic портирован 1-в-1 из плагина (rgMatch 0.15+0.35*termCoverage, ragScore×1.4, +0.10 за совпадение обоих, tags +0.20, path +0.15, importance, accessCount). Тестирование: typecheck (`tsc --noEmit --target es2022 --moduleResolution bundler --types node`) — clean. Smoke-tests всех 5 tools на реальной памяти (84 .md файла, 210 MB index.json): memory-search (6 keyword matches по "embedder"), memory-list (preferences 0 / repos 19 / technical 63 / workflows 2), memory-access (3→4 count, atomic), memory-doctor (all green), memory-save (auto-setup на пустой /tmp dir с git init + hook + remote + commit). + +## Почему +Плагин `@mathew-cf/opencode-memory` не вызывается из-за бага wrapper-пути MCP server (`MEMORY_WRAPPER_PATH` указывает на `@mathew-cf/rag-cli/bin/rag.js`, который не существует/несовместим — см. ADR-030, PR#57, PR#71). TS tools вызывают Python через `spawnSync` (паттерн `pipeline-status.ts`, `commit.ts`, `memory-setup.ts`) — нативный, без wrapper-hop. Это Фаза 2 миграции к единой облачной памяти (issue #94, ADR-030): Фаза 1 (PR#100) сделала embedder lazy-init (returns None без env), Фаза 2 (этот PR) — TS tools, Фаза 3 — удалить плагин + `memory-setup.ts` + `setup-memory.sh`. + +Прогрессивная модель (progressive enhancement): keyword (ripgrep) работает ВСЕГДА, semantic (Python+OpenRouter) как enhancement с fallback на keyword-only. Zero-config: первый `memory-save` авто-инициализирует память (mkdir + git init/clone + hook + 7 категорий) — не требует отдельного `memory-setup` вызова. Это упрощает onboarding и убирает зависимость от `OPENCODE_MEMORY_REMOTE` (хотя hook auto-push ставится только если remote set). + +## Pending +— +- Фаза 3 (отдельный PR): удалить `.opencode/tools/memory-setup.ts` + `.opencode/scripts/setup-memory.sh` + плагин `@mathew-cf/opencode-memory` из `opencode.json` `plugin` block + обновить AGENTS.md Tool Usage Policy таблицу (10 tools → 14 tools: добавить `memory-save`, `memory-search`, `memory-list`, `memory-access`, `memory-doctor`; убрать `memory-setup`). + +## Watch out +- **`@vscode/ripgrep` платформенный binary** — `require("@vscode/ripgrep").rgPath` возвращает путь к `@vscode/ripgrep-/bin/rg` (не к `@vscode/ripgrep/bin/rg`). На linux-x64: `@vscode/ripgrep-linux-x64/bin/rg`. Проверяется через `fs.existsSync(mod.rgPath)`. Если платформенный пакет не установлен (unsupported platform) → fallback на системный `rg` через `spawnSync("rg", ["--version"])`. Если оба недоступны → memory-search возвращает "No memories found" (keyword не работает, semantic может). memory-doctor показывает ❌. +- **`OPENCODE_MEMORY_REMOTE` опционален для memory-save** (в отличие от `setup-memory.sh`, который exit 1 без remote). memory-save авто-инициализирует git repo (`git init`) даже без remote — hook auto-push не ставится. Это делает zero-config возможным (новый пользователь без remote может начать писать память, push настроит позже через `OPENCODE_MEMORY_REMOTE`). Hook ставится ТОЛЬКО если remote set AND hook отсутствует/несовпадает. +- **`spawn(... detached: true) + child.unref()`** для async reindex — fire-and-forget, НЕ блокирует return memory-save. Если процесс opencode умрёт до завершения reindex, orphaned python процесс может остаться (но `.rag/.lock` через `fcntl.flock(LOCK_EX)` в `index.py` предотвращает concurrent reindex corruption). Reindex логирует прогресс в `.rag/reindex.log` (из PR#100). +- **Scoring weights портированы 1-в-1** из плагина `scoreCandidate` (`dist/index.js:12822-12852`). НЕ изменяй веса без ADR — это нарушит совместимость результатов поиска между плагином (если останется) и TS tools. Path hits +0.15 — это было в плагине (`input.path.toLowerCase().replace(/[-_/.]/g, " ")`), сохранено. +- **Typecheck требует `@types/node` + `typescript`** — добавлены как devDeps в `.opencode/package.json`. CI runner должен делать `npm install` в `.opencode/` перед typecheck. Runtime (opencode plugin loader) использует Bun/Node native TS, эти devDeps не нужны в production. +- **Frontmatter parsing упрощён** (regex `^(\w+):\s*(.+)$` per line, не полноценный YAML parser) — покрывает наш формат (плоские ключи + `[a, b]` для tags/related). Многострочные значения НЕ поддерживаются. Это тот же парсер что в плагине (`parseFrontmatter` `dist/index.js:12639-12676`). +- **memory-search cross-category fallback** — если `args.category` указан и дал 0 hits, search повторяет по всему `memoryDir` (без category filter). Это поведение плагина (`crossCategoryFallback`, `dist/index.js:12999-13022`). Сохранено для UX. +- **`spawnSync("python3", ["-c", "import src.memory"], {cwd: context.worktree})`** в memory-doctor — требует что `context.worktree` имеет `src/memory/` пакет importable (`.venv/bin/python` или system python с `src.memory` установлен). Если worktree не настроен (нет `.venv`, нет `pip install -e .`) → ❌, но это диагностический сигнал, не блокер. +- **`git -C memoryDir commit`** через `spawnSync` — НЕ через `commit` tool (тот для main repo, с conventional format validation). Memory repo commit message: `memory: sync ` (derived, не conventional format). Это тот же паттерн что в плагине `runSave` (`dist/index.js:13259-13260`). \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 6b369dd..4350c3c 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -44,6 +44,11 @@ opencode-config/ │ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates format+labels; optional repo?: string) — PR#38, PR#65 │ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65 │ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65 +│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101 +│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg, Python src.memory importability, env vars, memory dir, RAG index; markdown ✅/❌ report) — PR#101 +│ │ ├── memory-list.ts # memory-list tool (pure TS, categories count .md or files in category with frontmatter) — PR#101 +│ │ ├── memory-save.ts # memory-save tool (auto-setup mkdir+git init/clone+hook+7 categories, git commit, async reindex via spawn detached+unref) — PR#101 +│ │ ├── memory-search.ts # memory-search tool (ripgrep keyword + Python semantic via spawnSync + scoring port + cross-category fallback) — PR#101 │ │ ├── memory-setup.ts # memory-setup tool wrapper (0 args, calls setup-memory.sh) — PR#36 │ │ ├── pipeline-status.ts # pipeline-status tool wrapper │ │ ├── post-docs-review.ts # post-docs-review tool wrapper (3 args: pr_number, verdict enum, body; deterministic ## Docs Review Summary heading; optional repo?: string) — PR#46, PR#65 @@ -60,7 +65,7 @@ opencode-config/ │ │ ├── spec-status.py # 9-phase spec oracle │ │ └── tunnel.sh # Cloudflare tunnel toggle bash (named mode via CLOUDFLARE_TUNNEL_TOKEN) — PR#34 │ ├── opencode.json # MCP servers, providers, permissions, agents (role-based tools), plugins — PR#40, PR#69 -│ ├── package.json # npm deps for tools/*.ts +│ ├── package.json # npm deps for tools/*.ts (deps: @vscode/ripgrep; devDeps: @types/node, typescript) — PR#101 │ └── .gitignore # Ignores node_modules, etc. ├── docs/ │ ├── handoff/ # PR handoffs (pr--.md)