opencode-gotcha 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +313 -2
- package/index.js +41 -0
- package/package.json +51 -4
- package/src/context.js +52 -0
- package/src/directive.js +13 -0
- package/src/lock.js +79 -0
- package/src/markdown.js +70 -0
- package/src/origin.js +62 -0
- package/src/scope.js +105 -0
- package/src/store.js +210 -0
- package/src/tools-v1.js +15 -0
- package/src/tools.js +96 -0
- package/v2/index.js +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 voischev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,314 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-gotcha
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Память проверенных обходов и рабочих приёмов для [OpenCode](https://opencode.ai),
|
|
4
|
+
близкая к телу: агент узнаёт знакомую задачу или ловушку и использует успешный
|
|
5
|
+
опыт до очередной серии долгих итераций.
|
|
6
|
+
|
|
7
|
+
Публичная поставка — npm-пакет `opencode-gotcha`. Репозиторий разработки в SourceCraft закрытый.
|
|
8
|
+
|
|
9
|
+
**Первый MVP работает в OpenCode V1 и V2.** Он хранит зацепки в Markdown, предоставляет
|
|
10
|
+
пять инструментов, автоматически добавляет ранжированный индекс в контекст
|
|
11
|
+
и восстанавливает его после компакции и перезапуска. Проверена работа
|
|
12
|
+
в OpenCode **1.19.3** и **2.2.6**, включая установку из tarball в чистом окружении.
|
|
13
|
+
Собственных runtime-зависимостей нет; V2-вход не требует SDK,
|
|
14
|
+
для V1 используется опциональная peer-зависимость `@opencode-ai/plugin`.
|
|
15
|
+
|
|
16
|
+
Фоновое извлечение — следующий этап. Внутренняя документация разработки,
|
|
17
|
+
проектная память, тесты и CI-конфигурация в npm-пакет не входят.
|
|
18
|
+
|
|
19
|
+
## Быстрый старт
|
|
20
|
+
|
|
21
|
+
Для OpenCode V2 подключить npm-пакет в проектном `opencode.jsonc`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"$schema": "https://opencode.ai/config.json",
|
|
26
|
+
"plugins": [{
|
|
27
|
+
"package": "opencode-gotcha@latest",
|
|
28
|
+
"options": { "origin": { "scope": "local" } }
|
|
29
|
+
}]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Конфигурация V2 обновляется автоматически; принудительное обновление — `/reload`.
|
|
34
|
+
Индекс появится в контексте автоматически. При первом
|
|
35
|
+
сохранении создаётся `.opencode/gotcha/`; включайте его в обычный PR с кодом.
|
|
36
|
+
Для проверки пути можно попросить агента вызвать `gotcha_index`.
|
|
37
|
+
|
|
38
|
+
Также поддерживаются checkout и собранный tarball для участников закрытого репозитория.
|
|
39
|
+
V1-вход `index.js` сохранён; ему дополнительно нужен peer `@opencode-ai/plugin`.
|
|
40
|
+
V2 не импортирует SDK V1 и загружает npm-пакет через экспорт `./server`.
|
|
41
|
+
|
|
42
|
+
## Что такое зацепка
|
|
43
|
+
|
|
44
|
+
Зацепка — короткое прикладное знание: **при таких условиях возникает такая
|
|
45
|
+
задача или проблема; вот проверенный приём и успешный пример решения**.
|
|
46
|
+
|
|
47
|
+
Сохраняем проверенные обходы проблем и приёмы выполнения повторяющихся задач,
|
|
48
|
+
которые сокращают нетривиальный поиск или долгие итерации. Возникновение ошибки
|
|
49
|
+
необязательно: достаточно неочевидного рабочего способа, который пригодится
|
|
50
|
+
снова. Ценность зацепки — в экономии будущих итераций и предотвращении тупиков.
|
|
51
|
+
|
|
52
|
+
У записи должны быть:
|
|
53
|
+
|
|
54
|
+
- узнаваемая задача или симптом: цель работы, сообщение об ошибке, поведение инструмента;
|
|
55
|
+
- условия применимости: инструмент, окружение, версия или особенности проекта;
|
|
56
|
+
- конкретный рабочий приём: действия, команда или запрос;
|
|
57
|
+
- воспроизводимый успешный пример: исходные данные, действие и ожидаемый результат;
|
|
58
|
+
- основание: наблюдаемый результат проверки и, когда доступен, источник.
|
|
59
|
+
|
|
60
|
+
Неработающий путь добавляется, если он действительно был и его легко повторить.
|
|
61
|
+
Для повторяющейся задачи важно показать, какой поиск или какие действия экономит
|
|
62
|
+
приём. Частота задачи сама по себе недостаточна: запись должна сохранять полезный
|
|
63
|
+
неочевидный опыт.
|
|
64
|
+
|
|
65
|
+
### Что сохраняем
|
|
66
|
+
|
|
67
|
+
| Подходит | Что делает запись полезной |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| Обход повторяющегося сбоя инструмента | Узнаваемый симптом, условия и проверенный способ продолжить |
|
|
70
|
+
| Неочевидная особенность команды или API | Работающий пример с условиями и проверкой результата |
|
|
71
|
+
| Короткий путь после долгого поиска решения | Объяснение, когда он применим и какие итерации экономит |
|
|
72
|
+
| Приём выполнения повторяющейся задачи | Воспроизводимый успешный пример: входные данные, действия и ожидаемый результат |
|
|
73
|
+
|
|
74
|
+
Примеры повторяющихся задач: запуск нужной проверки без полной пересборки,
|
|
75
|
+
подбор рабочей формы сложного API-запроса или короткий сценарий проверки плагина.
|
|
76
|
+
Для каждой такой записи нужен проверенный пример и объяснение сокращённого пути.
|
|
77
|
+
|
|
78
|
+
Живой статус задачи остаётся в исходной системе. Разбор исправленного бага —
|
|
79
|
+
в задаче или PR; повторно применимый вывод из него может стать зацепкой.
|
|
80
|
+
Общие предпочтения пользователя, обычная справка и очевидные инструкции
|
|
81
|
+
относятся к соответствующим правилам и документации.
|
|
82
|
+
|
|
83
|
+
Если актуальный ответ уже есть в коде, документации, `AGENTS.md` или схеме
|
|
84
|
+
инструмента, зацепка ссылается на него и объясняет неочевидную ловушку или
|
|
85
|
+
сокращённый путь. Предположение о причине обозначается в тексте; рабочий приём
|
|
86
|
+
подтверждается проверкой. Условия и результат важнее уверенной формулировки.
|
|
87
|
+
|
|
88
|
+
## Как агент пользуется памятью
|
|
89
|
+
|
|
90
|
+
1. Получает компактный индекс зацепок в контексте работы.
|
|
91
|
+
2. Перед повторяющейся задачей, повторной попыткой после знакомой ошибки или
|
|
92
|
+
долгим перебором подходов проверяет индекс на подходящую задачу, симптомы и условия.
|
|
93
|
+
3. Читает полную релевантную запись через `gotcha_read`, сверяет её применимость
|
|
94
|
+
и проверяет результат выбранного приёма.
|
|
95
|
+
4. После найденного и проверенного приёма или обхода ищет существующую зацепку:
|
|
96
|
+
уточняет её или сохраняет новую через `gotcha_save`.
|
|
97
|
+
5. При пересмотре обновляет изменившиеся условия и удаляет потерявшие смысл записи.
|
|
98
|
+
|
|
99
|
+
Сохранённая зацепка — контекст прошлого опыта. Актуальные условия, код и
|
|
100
|
+
документация определяют, можно ли применить её сейчас.
|
|
101
|
+
|
|
102
|
+
## Где живут зацепки
|
|
103
|
+
|
|
104
|
+
Основной режим — **проектная память, которая хранится и коммитится вместе с кодом**:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
.opencode/gotcha/
|
|
108
|
+
├── .gitignore
|
|
109
|
+
├── MEMORY.md
|
|
110
|
+
├── bash-grep-sigpipe.md
|
|
111
|
+
└── <ключ>.md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Одна зацепка — один Markdown-файл со стабильным ключом. Ссылки в `MEMORY.md`
|
|
115
|
+
относительны каталогу хранилища. Плагин сообщает выбранный каталог агенту,
|
|
116
|
+
чтобы подробные записи находились независимо от рабочей директории.
|
|
117
|
+
|
|
118
|
+
Проектные записи и индекс включаются в обычный PR. Так участники получают
|
|
119
|
+
зацепки после checkout, а изменение приёма можно обсудить вместе с кодом.
|
|
120
|
+
Сам плагин обновляет файлы; Git-коммиты и публикация выполняются в рамках
|
|
121
|
+
обычного процесса работы с репозиторием.
|
|
122
|
+
|
|
123
|
+
### Выбор хранилища
|
|
124
|
+
|
|
125
|
+
Место подключения плагина задаёт область по умолчанию. Существующая проектная
|
|
126
|
+
память имеет приоритет:
|
|
127
|
+
|
|
128
|
+
| Ситуация | Выбранное хранилище |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| В проектной области OpenCode уже есть `.opencode/gotcha/` | Проектное, в том числе при глобальном подключении плагина |
|
|
131
|
+
| Плагин подключён локально, проектного хранилища ещё нет | Проектное; создаётся при первом сохранении |
|
|
132
|
+
| Плагин подключён глобально, проектного хранилища нет | `<каталог глобальной конфигурации OpenCode>/gotcha/` |
|
|
133
|
+
|
|
134
|
+
Обычно глобальный каталог — `~/.config/opencode/gotcha/`. Проектные записи
|
|
135
|
+
физически находятся в проекте; глобальная память — самостоятельный карман
|
|
136
|
+
общих зацепок.
|
|
137
|
+
|
|
138
|
+
Проектный путь определяется относительно проектной конфигурации или локального
|
|
139
|
+
каталога плагинов, который обнаружил OpenCode. Запуск из подкаталога выбирает
|
|
140
|
+
то же хранилище этой проектной области. Вложенная проектная конфигурация задаёт
|
|
141
|
+
собственную область; worktree с собственной копией конфигурации и памяти
|
|
142
|
+
использует её.
|
|
143
|
+
|
|
144
|
+
Для каждой операции выбирается одно хранилище, общее для инструментов и инъекции.
|
|
145
|
+
Путь к npm-модулю в кэше OpenCode сам по себе не определяет область подключения.
|
|
146
|
+
|
|
147
|
+
## Ранжированный индекс
|
|
148
|
+
|
|
149
|
+
`MEMORY.md` — читаемый индекс и единственный источник счётчиков и дат:
|
|
150
|
+
|
|
151
|
+
```markdown
|
|
152
|
+
# MEMORY
|
|
153
|
+
|
|
154
|
+
| Использований | Запись | Описание | Создана | Обновлена | Последнее использование |
|
|
155
|
+
| ---: | --- | --- | --- | --- | --- |
|
|
156
|
+
| 3 | [bash-grep-sigpipe](bash-grep-sigpipe.md) | Exit 141 при grep -q и включённом pipefail | 2026-10-28 | 2026-10-28 | 2026-10-28 |
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- Создание зацепки = **1 использование**.
|
|
160
|
+
- Каждый успешный `gotcha_read` добавляет **1** и обновляет дату использования.
|
|
161
|
+
- Повторное сохранение обновляет содержимое, описание и дату изменения,
|
|
162
|
+
сохраняя накопленный счётчик, даты создания и последнего использования.
|
|
163
|
+
- Порядок строк — по убыванию счётчика, при равенстве — по ключу.
|
|
164
|
+
- Показ индекса, пересмотр, инъекция и компакция счётчики не увеличивают.
|
|
165
|
+
- Метрики находятся в таблице. Изменения индекса записываются атомарно;
|
|
166
|
+
работа нескольких процессов с одним хранилищем требует блокировки записи.
|
|
167
|
+
|
|
168
|
+
«Использование» здесь означает создание или явное чтение полной записи.
|
|
169
|
+
Это показатель востребованности, а не доказательство успешного применения
|
|
170
|
+
приёма. Популярность определяет порядок просмотра; задача, симптомы и условия —
|
|
171
|
+
релевантность конкретной задаче.
|
|
172
|
+
|
|
173
|
+
В контекст попадает индекс, подробности читаются по необходимости. При росте
|
|
174
|
+
индекса сначала сокращаются описания, затем остаются ключи в ранжированном
|
|
175
|
+
порядке. Целевой размер индекса — 20 000 байт; при очень большом числе записей
|
|
176
|
+
список ключей может превышать этот размер, поскольку ключи не отбрасываются.
|
|
177
|
+
Сжатие выполняется для запроса без переписывания пользовательских файлов.
|
|
178
|
+
|
|
179
|
+
## Формат записи
|
|
180
|
+
|
|
181
|
+
```markdown
|
|
182
|
+
# Короткое название зацепки
|
|
183
|
+
|
|
184
|
+
## Когда пригодится
|
|
185
|
+
Повторяющаяся задача или симптом; условия, инструмент, окружение и версия.
|
|
186
|
+
|
|
187
|
+
## Что не помогает (необязательно)
|
|
188
|
+
Неудачный подход, если он был и его легко повторить.
|
|
189
|
+
|
|
190
|
+
## Рабочий приём
|
|
191
|
+
Конкретные действия и объяснение, какой поиск или какие итерации они сокращают.
|
|
192
|
+
|
|
193
|
+
## Успешный пример
|
|
194
|
+
Исходные данные, команда или запрос и ожидаемый результат.
|
|
195
|
+
|
|
196
|
+
## Как проверено
|
|
197
|
+
Наблюдаемый результат, условия проверки и доступный источник.
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Пример: grep находит строку, но конвейер завершается с кодом 141
|
|
201
|
+
|
|
202
|
+
Ниже — пример готовой зацепки. Команды проверены для Bash с `pipefail`:
|
|
203
|
+
получены коды 141 для раннего завершения, 0 при совпадении после обхода
|
|
204
|
+
и 1 при отсутствии совпадения.
|
|
205
|
+
|
|
206
|
+
````markdown
|
|
207
|
+
# Раннее завершение grep -q вызывает SIGPIPE у производителя
|
|
208
|
+
|
|
209
|
+
## Когда пригодится
|
|
210
|
+
В Bash включён pipefail. Достаточно большой конечный поток передан в grep -q;
|
|
211
|
+
совпадение встречается в начале. grep завершается с кодом 0, но производитель
|
|
212
|
+
получает SIGPIPE, и весь конвейер возвращает 141.
|
|
213
|
+
|
|
214
|
+
## Что не помогает
|
|
215
|
+
Повторять тот же конвейер: проблема связана с ранним закрытием канала,
|
|
216
|
+
а не с отсутствием искомой строки.
|
|
217
|
+
|
|
218
|
+
## Рабочий приём
|
|
219
|
+
Для конечного потока дать grep дочитать вход, перенаправив найденные строки
|
|
220
|
+
в /dev/null. Обход применим, когда допустимо дождаться завершения производителя.
|
|
221
|
+
Для бесконечного потока нужен другой способ остановки производителя.
|
|
222
|
+
|
|
223
|
+
## Успешный пример
|
|
224
|
+
Следующий конечный поток содержит строку 1. Команда возвращает 0:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
set -o pipefail
|
|
228
|
+
seq 100000 | grep '^1$' > /dev/null
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Как проверено
|
|
232
|
+
В Bash с pipefail команда seq 100000 | grep -q '^1$' возвращает 141.
|
|
233
|
+
Вариант без -q с перенаправлением в /dev/null возвращает 0.
|
|
234
|
+
Замена шаблона на '^0$' возвращает 1: проверка отсутствия совпадения сохраняется.
|
|
235
|
+
````
|
|
236
|
+
|
|
237
|
+
### Пример: повторно проверять счётчики без запуска OpenCode
|
|
238
|
+
|
|
239
|
+
Это пример приёма для повторяющейся задачи без предшествующей ошибки.
|
|
240
|
+
Он относится к файловому ядру в этом репозитории и опирается на
|
|
241
|
+
самодостаточный тест в [test/store.test.mjs](test/store.test.mjs).
|
|
242
|
+
|
|
243
|
+
````markdown
|
|
244
|
+
# Проверять счётчики и сортировку отдельным тестом хранилища
|
|
245
|
+
|
|
246
|
+
## Когда пригодится
|
|
247
|
+
В этом репозитории нужно повторно проверить изменение счётчиков и порядок
|
|
248
|
+
индекса. В test/store.test.mjs есть самодостаточный тест, который создаёт
|
|
249
|
+
изолированное хранилище и вызывает методы GotchaStore напрямую.
|
|
250
|
+
|
|
251
|
+
## Рабочий приём
|
|
252
|
+
Из корня репозитория запустить только этот сценарий через фильтр node:test.
|
|
253
|
+
Это сокращает ручную проверку с запуском OpenCode и последовательностью вызовов
|
|
254
|
+
инструментов. Сценарий проверяет файловые операции, а интеграция с хостом
|
|
255
|
+
проверяется отдельно.
|
|
256
|
+
|
|
257
|
+
## Успешный пример
|
|
258
|
+
Нужны установленные зависимости проекта. Команда:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
node --test --test-name-pattern='^индекс ранжируется по использованию, при равенстве — по ключу$' test/store.test.mjs
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Ожидаемый результат: один прошедший тест и код завершения 0. Сценарий создаёт
|
|
265
|
+
три записи со счётчиком 1, читает beta один раз и gamma дважды, затем уточняет
|
|
266
|
+
gamma. Итоговый порядок: gamma — 3, beta — 2, alpha — 1.
|
|
267
|
+
|
|
268
|
+
## Как проверено
|
|
269
|
+
Команда выполнена: один тест прошёл, код завершения 0. Проверяются также
|
|
270
|
+
сохранение счётчика при обновлении и порядок после создания нового экземпляра ядра.
|
|
271
|
+
Источник: test/store.test.mjs, тест «индекс ранжируется по использованию,
|
|
272
|
+
при равенстве — по ключу».
|
|
273
|
+
````
|
|
274
|
+
|
|
275
|
+
## Инструменты первого MVP
|
|
276
|
+
|
|
277
|
+
Эти инструменты предоставляет основной вход `opencode-gotcha`.
|
|
278
|
+
Параметры и критерии сохранения доступны агенту во встроенных описаниях инструментов.
|
|
279
|
+
|
|
280
|
+
| Инструмент | Назначение |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| `gotcha_save` | Создать или уточнить зацепку, обновить индекс |
|
|
283
|
+
| `gotcha_read` | Прочитать полную запись и увеличить счётчик |
|
|
284
|
+
| `gotcha_index` | Показать ранжированный индекс |
|
|
285
|
+
| `gotcha_forget` | Удалить запись и её строку индекса |
|
|
286
|
+
| `gotcha_review` | Пересмотреть записи, их востребованность и условия применимости |
|
|
287
|
+
|
|
288
|
+
В первом MVP агент следует директиве плагина и сам вызывает сохранение, когда
|
|
289
|
+
найден проверенный повторно применимый обход или рабочий приём для задачи.
|
|
290
|
+
Фоновое извлечение оценивается отдельным этапом на реальных сессиях.
|
|
291
|
+
|
|
292
|
+
## Поэтапное развитие
|
|
293
|
+
|
|
294
|
+
Каждый этап — отдельный небольшой PR с проверяемым результатом.
|
|
295
|
+
Этапы 1–6 завершены: первый MVP готов. Следующий этап — фоновое извлечение
|
|
296
|
+
зацепок из реальных сессий.
|
|
297
|
+
|
|
298
|
+
| Этап | Состав | Критерий готовности |
|
|
299
|
+
| --- | --- | --- |
|
|
300
|
+
| 1. Концепция | Назначение, критерии сохранения, область памяти, формат и примеры | Понятно, что сохранять и когда читать |
|
|
301
|
+
| 2. Каркас и хранилище | Пакет opencode-gotcha, файлы записей, перенос ранжированного индекса, блокировка записи | Файловые операции проверяются независимо от OpenCode, включая несколько процессов |
|
|
302
|
+
| 3. Область памяти | Выбор проектного или глобального каталога | Локальное и глобальное подключение, подкаталоги и worktree выбирают ожидаемое хранилище |
|
|
303
|
+
| 4. Инструменты | gotcha_save/read/index/forget/review и директива использования | Агент проходит полный цикл работы с зацепкой |
|
|
304
|
+
| 5. Контекст | Инъекция индекса, восстановление после перезапуска и компакции | Зацепки доступны в новой и продолженной сессии |
|
|
305
|
+
| 6. Установка | Инструкции и проверка упакованного пакета в чистом окружении | Установка и ручная проверка в OpenCode воспроизводимы |
|
|
306
|
+
| 7. Фоновое извлечение | Поиск приёмов для повторяющихся задач и обходов после неудач или долгих итераций | Извлекаются проверенные приёмы с успешными примерами, без самоповторов |
|
|
307
|
+
|
|
308
|
+
Основные части реализации: хранилище, выбор области, инструменты и подключение
|
|
309
|
+
к контексту. Адаптеры V1 и V2 используют общее ядро, схемы и выполнение
|
|
310
|
+
инструментов; ядро проверяется отдельно.
|
|
311
|
+
|
|
312
|
+
При извлечении учитываются результаты инструментов и проверок. Синтетические
|
|
313
|
+
инъекции, служебные запросы и уже показанные зацепки отделяются от нового опыта.
|
|
314
|
+
Повторный опыт уточняет существующую запись.
|
package/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createScopedStore } from "./src/scope.js";
|
|
2
|
+
import { selectPluginOrigin } from "./src/origin.js";
|
|
3
|
+
import { createGotchaToolsV1 } from "./src/tools-v1.js";
|
|
4
|
+
import { createContextReader } from "./src/context.js";
|
|
5
|
+
|
|
6
|
+
export const GotchaPlugin = async (ctx, options = {}) => {
|
|
7
|
+
let config;
|
|
8
|
+
let selection;
|
|
9
|
+
|
|
10
|
+
function getSelected() {
|
|
11
|
+
if (!selection) {
|
|
12
|
+
selection = (async () => {
|
|
13
|
+
const origin = await selectPluginOrigin(config, import.meta.url, options.origin, ctx.directory);
|
|
14
|
+
return createScopedStore({
|
|
15
|
+
directory: ctx.directory, worktree: ctx.worktree, origin,
|
|
16
|
+
globalConfigDirectory: options.globalConfigDirectory,
|
|
17
|
+
});
|
|
18
|
+
})().catch((error) => {
|
|
19
|
+
selection = undefined;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return selection;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const context = createContextReader(getSelected, (error) =>
|
|
27
|
+
ctx.client?.app?.log({ body: { service: "opencode-gotcha", level: "error", message: error.message } }));
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
config: async (loaded) => { config = loaded; },
|
|
31
|
+
tool: createGotchaToolsV1(getSelected),
|
|
32
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
33
|
+
output.system.push(await context());
|
|
34
|
+
},
|
|
35
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
36
|
+
const memory = await context();
|
|
37
|
+
if (output.prompt !== undefined) output.prompt += `\n\n${memory}`;
|
|
38
|
+
else output.context.push(memory);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-gotcha",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Память проверенных обходов и рабочих приёмов для повторяющихся задач в OpenCode.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./server": "./v2/index.js",
|
|
10
|
+
"./v2": "./v2/index.js",
|
|
11
|
+
"./store": "./src/store.js",
|
|
12
|
+
"./scope": "./src/scope.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"v2",
|
|
17
|
+
"src",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@opencode-ai/plugin": ">=1.18.21 <2"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"@opencode-ai/plugin": { "optional": true }
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "1.18.21"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.mjs",
|
|
31
|
+
"smoke:package": "node scripts/package-smoke.mjs",
|
|
32
|
+
"smoke:v2": "node scripts/v2-smoke.mjs",
|
|
33
|
+
"release": "node scripts/release.mjs",
|
|
34
|
+
"check": "node --check index.js && node --check v2/index.js && node --check src/markdown.js && node --check src/store.js && node --check src/lock.js && node --check src/scope.js && node --check src/origin.js && node --check src/tools.js && node --check src/tools-v1.js && node --check src/directive.js && node --check src/context.js && node --check scripts/release.mjs"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://git.sourcecraft.dev/voischev/opencode-gotcha.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://www.npmjs.com/package/opencode-gotcha",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"registry": "https://registry.npmjs.org/"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"opencode",
|
|
47
|
+
"opencode-plugin",
|
|
48
|
+
"plugin",
|
|
49
|
+
"memory",
|
|
50
|
+
"gotcha"
|
|
51
|
+
],
|
|
52
|
+
"license": "MIT"
|
|
53
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { parseIndex, rankEntries, usageCount } from "./markdown.js";
|
|
2
|
+
import { gotchaDirective } from "./directive.js";
|
|
3
|
+
|
|
4
|
+
export const INDEX_TARGET_BYTES = 20000;
|
|
5
|
+
|
|
6
|
+
function rows(entries, descriptionLength) {
|
|
7
|
+
return entries.map((entry) => {
|
|
8
|
+
const link = `- [${entry.key}](${entry.filename})`;
|
|
9
|
+
if (descriptionLength === 0) return link;
|
|
10
|
+
const description = entry.desc.length > descriptionLength
|
|
11
|
+
? `${Array.from(entry.desc).slice(0, descriptionLength).join("")}…`
|
|
12
|
+
: entry.desc;
|
|
13
|
+
return `${link} (${entry.accessCount})${description ? ` — ${description}` : ""}`;
|
|
14
|
+
}).join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatContextIndex(index) {
|
|
18
|
+
const entries = rankEntries(parseIndex(index).map((entry) => ({ ...entry, accessCount: usageCount(entry.accessCount) })));
|
|
19
|
+
if (entries.length === 0) {
|
|
20
|
+
return index.trim()
|
|
21
|
+
? "В индексе нет распознанных записей. Полный текст доступен через gotcha_index."
|
|
22
|
+
: "Хранилище пока пусто. Сохраняй проверенные приёмы через gotcha_save.";
|
|
23
|
+
}
|
|
24
|
+
const full = rows(entries, Infinity);
|
|
25
|
+
if (Buffer.byteLength(full, "utf8") <= INDEX_TARGET_BYTES) return full;
|
|
26
|
+
const notice = "Индекс большой: описания сокращены; полный индекс — gotcha_index. Пересмотр — gotcha_review.\n";
|
|
27
|
+
const shortened = notice + rows(entries, 40);
|
|
28
|
+
if (Buffer.byteLength(shortened, "utf8") <= INDEX_TARGET_BYTES) return shortened;
|
|
29
|
+
return "Индекс большой: показаны все ключи без описаний; полный индекс — gotcha_index. Пересмотр — gotcha_review.\n" + rows(entries, 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function gotchaContext(selected) {
|
|
33
|
+
const index = await selected.store.index();
|
|
34
|
+
return `${gotchaDirective(selected)}\n\n## Актуальный индекс зацепок\n\n${formatContextIndex(index)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createContextReader(getSelected, report) {
|
|
38
|
+
let reportedError;
|
|
39
|
+
return async () => {
|
|
40
|
+
try {
|
|
41
|
+
const result = await gotchaContext(await getSelected());
|
|
42
|
+
reportedError = undefined;
|
|
43
|
+
return result;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (reportedError !== error.message) {
|
|
46
|
+
reportedError = error.message;
|
|
47
|
+
try { await report?.(error); } catch {}
|
|
48
|
+
}
|
|
49
|
+
return `${gotchaDirective()}\nИндекс сейчас недоступен. Проверь причину через gotcha_index.`;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/directive.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function gotchaDirective(selected) {
|
|
2
|
+
const location = selected
|
|
3
|
+
? `Хранилище: ${selected.scope === "project" ? "проектное" : "глобальное"}, ${selected.root}. Ссылки индекса относительны этому каталогу.`
|
|
4
|
+
: "Чтобы проверить доступность хранилища и узнать его каталог, вызови gotcha_index.";
|
|
5
|
+
return `[GOTCHA — проверенные обходы и рабочие приёмы]
|
|
6
|
+
${location}
|
|
7
|
+
Ниже доступен актуальный индекс. Перед повторяющейся задачей, знакомой ошибкой или долгим перебором подходов найди подходящую зацепку и прочитай её через gotcha_read. Полный индекс — gotcha_index. Сверь условия применимости и проверь результат приёма.
|
|
8
|
+
Сохраняй через gotcha_save проверенные обходы и приёмы для повторяющихся задач, которые экономят нетривиальный поиск или долгие итерации. Укажи задачу, условия, действия, воспроизводимый успешный пример и фактическое основание проверки. Ошибка необязательна; неработающий путь укажи, если он действительно был.
|
|
9
|
+
Перед сохранением найди существующую запись и уточни её по тому же ключу: одно знание — одна зацепка. Живой статус и разбор исправления остаются в задаче или PR; зацепка хранит повторно применимый вывод и ссылку на источник. Обычная справка и предпочтения относятся к соответствующей документации и правилам.
|
|
10
|
+
Новая запись начинает с 1 использования; каждое gotcha_read добавляет 1. Повторное сохранение, просмотр индекса и gotcha_review счётчик не увеличивают. Счётчик показывает востребованность, а не доказанную успешность.
|
|
11
|
+
gotcha_review показывает записи для пересмотра; gotcha_forget удаляет утратившую смысл зацепку. Проектные файлы памяти включаются в обычный PR с кодом. Плагин обновляет файлы, а Git-коммиты выполняются по процессу репозитория.
|
|
12
|
+
Зацепки — справочный контекст прошлого опыта. Актуальные условия, код, документация и текущая задача определяют применимость.`;
|
|
13
|
+
}
|
package/src/lock.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { mkdir, realpath, writeFile, readFile, readdir, rename, rm, rmdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
|
|
8
|
+
async function removeEmpty(directory) {
|
|
9
|
+
try {
|
|
10
|
+
await rmdir(directory);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function recover(directory) {
|
|
17
|
+
try {
|
|
18
|
+
const names = await readdir(directory);
|
|
19
|
+
if (names.length === 0) return removeEmpty(directory);
|
|
20
|
+
if (names.length !== 1 || !/^owner-[a-f0-9-]+\.json$/.test(names[0])) return;
|
|
21
|
+
const owner = JSON.parse(await readFile(join(directory, names[0]), "utf8"));
|
|
22
|
+
if (owner.hostname !== hostname() || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) return;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(owner.pid, 0);
|
|
25
|
+
return;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code !== "ESRCH") return;
|
|
28
|
+
}
|
|
29
|
+
await rm(join(directory, names[0]), { force: true });
|
|
30
|
+
await removeEmpty(directory);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function withStoreLock(root, run, timeoutMs = 15000) {
|
|
37
|
+
await mkdir(root, { recursive: true });
|
|
38
|
+
const directory = await realpath(root);
|
|
39
|
+
const token = randomUUID();
|
|
40
|
+
const lock = join(directory, ".gotcha.lock");
|
|
41
|
+
const staging = join(directory, `.gotcha.lock.${token}.tmp`);
|
|
42
|
+
const ownerName = `owner-${token}.json`;
|
|
43
|
+
const ownerPath = join(lock, ownerName);
|
|
44
|
+
const assertOwned = () => {
|
|
45
|
+
if (!existsSync(ownerPath)) throw new Error(`Потеряна блокировка хранилища ${directory}`);
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
await mkdir(staging);
|
|
49
|
+
await writeFile(join(staging, ownerName), JSON.stringify({ pid: process.pid, hostname: hostname() }), { flag: "wx" });
|
|
50
|
+
const deadline = performance.now() + timeoutMs;
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
await rename(staging, lock);
|
|
55
|
+
break;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
|
|
58
|
+
}
|
|
59
|
+
await recover(lock);
|
|
60
|
+
const remaining = deadline - performance.now();
|
|
61
|
+
if (remaining <= 0) throw new Error("Истекло время ожидания блокировки");
|
|
62
|
+
await delay(Math.min(25, remaining));
|
|
63
|
+
}
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
throw new Error(`Не удалось заблокировать хранилище ${directory}: ${cause.message}`, { cause });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
assertOwned();
|
|
69
|
+
const result = await run(assertOwned);
|
|
70
|
+
assertOwned();
|
|
71
|
+
return result;
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(ownerPath, { force: true });
|
|
74
|
+
await removeEmpty(lock);
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(staging, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
2
|
+
const INDEX_HEADER = "| Использований | Запись | Описание | Создана | Обновлена | Последнее использование |";
|
|
3
|
+
const INDEX_SEPARATOR = "| ---: | --- | --- | --- | --- | --- |";
|
|
4
|
+
|
|
5
|
+
export function keyError(key) {
|
|
6
|
+
if (typeof key !== "string" || !KEY_RE.test(key)) {
|
|
7
|
+
return "ошибка: ключ должен быть из букв/цифр/_/- (макс. 64 символа)";
|
|
8
|
+
}
|
|
9
|
+
return key.toLowerCase() === "memory" ? "ошибка: ключ MEMORY зарезервирован для индекса" : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function descriptionFromValue(value) {
|
|
13
|
+
const text = value.trim().replace(/\s+/g, " ");
|
|
14
|
+
return (text.split(/[.!?\n]/)[0].trim() || text).slice(0, 160);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function usageCount(value) {
|
|
18
|
+
const count = Number(value);
|
|
19
|
+
return Number.isSafeInteger(count) && count >= 1 ? count : 1;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function indexDate(value) {
|
|
23
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value ?? "") ? value : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rankEntries(entries) {
|
|
27
|
+
return entries.sort((a, b) =>
|
|
28
|
+
b.accessCount - a.accessCount || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encodeCell(value) {
|
|
32
|
+
return String(value ?? "").replace(/&/g, "&").replace(/\|/g, "|").replace(/\s+/g, " ").trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeCell(value) {
|
|
36
|
+
return value.trim().replace(/\\\|/g, "|").replace(/|/g, "|").replace(/&/g, "&");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseIndexLine(line) {
|
|
40
|
+
const text = line.trim();
|
|
41
|
+
if (text.startsWith("|")) {
|
|
42
|
+
const cells = text.split(/(?<!\\)\|/).slice(1, -1).map(decodeCell);
|
|
43
|
+
if (cells.length !== 6) return null;
|
|
44
|
+
const link = cells[1].match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
|
45
|
+
if (!link || keyError(link[1])) return null;
|
|
46
|
+
return {
|
|
47
|
+
key: link[1], filename: link[2], desc: cells[2], accessCount: usageCount(cells[0]),
|
|
48
|
+
created: indexDate(cells[3]), updated: indexDate(cells[4]), lastUsed: indexDate(cells[5]),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const match = text.match(/^- \[([^\]]+)\]\(([^)]+)\)(.*)$/);
|
|
52
|
+
if (!match || keyError(match[1])) return null;
|
|
53
|
+
return { key: match[1], filename: match[2], desc: match[3].replace(/^ —\s*/, "").trim() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseIndex(index) {
|
|
57
|
+
return index.split("\n").map(parseIndexLine).filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderIndex(index, entries) {
|
|
61
|
+
const lines = (index.trim() ? index : "# MEMORY\n").split("\n");
|
|
62
|
+
const managed = lines.map((line, i) => Boolean(parseIndexLine(line)) || line.trim() === INDEX_HEADER ||
|
|
63
|
+
(i > 0 && lines[i - 1].trim() === INDEX_HEADER && /^\|(?:\s*:?-+:?\s*\|){6}$/.test(line.trim())));
|
|
64
|
+
const first = managed.indexOf(true);
|
|
65
|
+
const kept = lines.filter((_line, i) => !managed[i]);
|
|
66
|
+
const table = [INDEX_HEADER, INDEX_SEPARATOR, ...rankEntries(entries).map((entry) =>
|
|
67
|
+
`| ${[entry.accessCount, `[${entry.key}](${entry.filename})`, entry.desc, entry.created, entry.updated, entry.lastUsed].map(encodeCell).join(" | ")} |`)];
|
|
68
|
+
kept.splice(first < 0 ? kept.length : first, 0, ...table);
|
|
69
|
+
return `${kept.join("\n").trimEnd()}\n`;
|
|
70
|
+
}
|
package/src/origin.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
async function physical(path) {
|
|
6
|
+
try {
|
|
7
|
+
return await realpath(path);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (error.code !== "ENOENT") throw error;
|
|
10
|
+
return resolve(path);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pathValue(value) {
|
|
15
|
+
if (value instanceof URL || typeof value === "string" && value.startsWith("file:")) {
|
|
16
|
+
return fileURLToPath(value);
|
|
17
|
+
}
|
|
18
|
+
return typeof value === "string" && isAbsolute(value) ? value : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function specPath(spec, source, workingDirectory) {
|
|
22
|
+
const absolute = pathValue(spec);
|
|
23
|
+
if (absolute) return physical(absolute);
|
|
24
|
+
if (!/^\.{1,2}[\\/]/.test(spec)) return null;
|
|
25
|
+
const path = pathValue(source);
|
|
26
|
+
if (!path) {
|
|
27
|
+
const base = source === "OPENCODE_CONFIG_CONTENT" && pathValue(workingDirectory);
|
|
28
|
+
return base ? physical(resolve(base, spec)) : null;
|
|
29
|
+
}
|
|
30
|
+
let directory = path;
|
|
31
|
+
try {
|
|
32
|
+
if (!(await stat(path)).isDirectory()) directory = dirname(path);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== "ENOENT") throw error;
|
|
35
|
+
directory = dirname(path);
|
|
36
|
+
}
|
|
37
|
+
return physical(resolve(directory, spec));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function selectPluginOrigin(config, moduleURL, explicitOrigin, workingDirectory) {
|
|
41
|
+
const modulePath = await physical(fileURLToPath(moduleURL));
|
|
42
|
+
const origins = config?.plugin_origins ?? [];
|
|
43
|
+
if (!Array.isArray(origins)) throw new Error("Некорректные метаданные происхождения плагинов OpenCode");
|
|
44
|
+
const candidates = [];
|
|
45
|
+
for (const origin of origins) {
|
|
46
|
+
if (!origin || typeof origin !== "object") continue;
|
|
47
|
+
const spec = Array.isArray(origin.spec) ? origin.spec[0] : origin.spec;
|
|
48
|
+
if (typeof spec !== "string") continue;
|
|
49
|
+
const path = await specPath(spec, origin.source, workingDirectory);
|
|
50
|
+
if (/^opencode-gotcha(?:@[^/]+)?$/.test(spec) || path === modulePath || path === dirname(modulePath)) {
|
|
51
|
+
candidates.push({ scope: origin.scope, source: origin.source });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const unique = [...new Map(candidates.map((origin) => [JSON.stringify(origin), origin])).values()];
|
|
55
|
+
if (unique.length > 1) {
|
|
56
|
+
const matches = explicitOrigin && unique.filter((origin) =>
|
|
57
|
+
origin.scope === explicitOrigin.scope && String(origin.source) === String(explicitOrigin.source));
|
|
58
|
+
if (matches?.length === 1) return matches[0];
|
|
59
|
+
throw new Error("Неоднозначный источник opencode-gotcha: укажите origin для этого подключения");
|
|
60
|
+
}
|
|
61
|
+
return unique[0] ?? explicitOrigin;
|
|
62
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { GotchaStore } from "./store.js";
|
|
6
|
+
|
|
7
|
+
function absolutePath(value, label) {
|
|
8
|
+
if (value instanceof URL || typeof value === "string" && value.startsWith("file:")) {
|
|
9
|
+
value = fileURLToPath(value);
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== "string" || !isAbsolute(value)) {
|
|
12
|
+
throw new TypeError(`${label} должен быть абсолютным путём`);
|
|
13
|
+
}
|
|
14
|
+
return resolve(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function info(path) {
|
|
18
|
+
try {
|
|
19
|
+
return await stat(path);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error.code !== "ENOENT") throw error;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function canonical(path) {
|
|
27
|
+
try {
|
|
28
|
+
return await realpath(path);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code !== "ENOENT") throw error;
|
|
31
|
+
const parent = dirname(path);
|
|
32
|
+
if (parent === path) throw error;
|
|
33
|
+
return join(await canonical(parent), basename(path));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function inside(root, path) {
|
|
38
|
+
const rel = relative(root, path);
|
|
39
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function sourceDirectory(source) {
|
|
43
|
+
if (!source || source === "OPENCODE_CONFIG_CONTENT" || /^https?:/.test(String(source))) return null;
|
|
44
|
+
const path = absolutePath(source, "Источник подключения");
|
|
45
|
+
const state = await info(path);
|
|
46
|
+
let directory = state?.isDirectory() || !state && !extname(path) ? path : dirname(path);
|
|
47
|
+
if (["plugin", "plugins"].includes(basename(directory))) directory = dirname(directory);
|
|
48
|
+
return canonical(directory);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function projectArea(directory, boundary, globalConfigDirectory) {
|
|
52
|
+
if (inside(globalConfigDirectory, directory)) return null;
|
|
53
|
+
for (let current = directory; ; current = dirname(current)) {
|
|
54
|
+
const configDirectory = join(current, ".opencode");
|
|
55
|
+
const memory = join(configDirectory, "gotcha");
|
|
56
|
+
const memoryInfo = await info(memory);
|
|
57
|
+
if (memoryInfo && !memoryInfo.isDirectory()) throw new Error(`Хранилище должно быть каталогом: ${memory}`);
|
|
58
|
+
if (memoryInfo) return { projectRoot: current, root: await canonical(memory), existing: true };
|
|
59
|
+
const configInfo = await info(configDirectory);
|
|
60
|
+
if (configInfo && !configInfo.isDirectory()) throw new Error(`Ожидался каталог конфигурации: ${configDirectory}`);
|
|
61
|
+
if (configInfo || (await info(join(current, "opencode.json")))?.isFile() ||
|
|
62
|
+
(await info(join(current, "opencode.jsonc")))?.isFile()) {
|
|
63
|
+
return { projectRoot: current, root: join(configDirectory, "gotcha"), existing: false };
|
|
64
|
+
}
|
|
65
|
+
if (current === boundary || dirname(current) === current) return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function resolveScope({ directory, worktree, origin, globalConfigDirectory } = {}) {
|
|
70
|
+
const workingDirectory = await realpath(absolutePath(directory, "Рабочий каталог"));
|
|
71
|
+
if (!(await stat(workingDirectory)).isDirectory()) throw new TypeError("Рабочий каталог должен быть каталогом");
|
|
72
|
+
const boundary = worktree ? await realpath(absolutePath(worktree, "Worktree")) : undefined;
|
|
73
|
+
if (boundary && !inside(boundary, workingDirectory)) throw new Error("Рабочий каталог находится за пределами worktree");
|
|
74
|
+
if (origin && !["local", "global"].includes(origin.scope)) throw new TypeError("Область подключения должна быть local или global");
|
|
75
|
+
|
|
76
|
+
const source = await sourceDirectory(origin?.source);
|
|
77
|
+
const defaultGlobal = process.env.OPENCODE_CONFIG_DIR ||
|
|
78
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode");
|
|
79
|
+
const globalDirectory = await canonical(absolutePath(
|
|
80
|
+
globalConfigDirectory ?? (origin?.scope === "global" ? source : null) ?? defaultGlobal,
|
|
81
|
+
"Глобальный каталог конфигурации",
|
|
82
|
+
));
|
|
83
|
+
const area = await projectArea(workingDirectory, boundary, globalDirectory);
|
|
84
|
+
if (area?.existing) {
|
|
85
|
+
return { scope: "project", root: area.root, projectRoot: area.projectRoot, reason: "project-store", source: origin?.source ?? null };
|
|
86
|
+
}
|
|
87
|
+
if (!origin) throw new Error("Неизвестна область подключения плагина: нужен источник конфигурации OpenCode");
|
|
88
|
+
if (origin.scope === "local") {
|
|
89
|
+
const sourceRoot = source && basename(source) === ".opencode" ? dirname(source) : source;
|
|
90
|
+
if (sourceRoot && !inside(sourceRoot, workingDirectory)) throw new Error("Локальная конфигурация находится вне рабочей проектной области");
|
|
91
|
+
const projectRoot = area?.projectRoot ?? sourceRoot ?? workingDirectory;
|
|
92
|
+
if (boundary && !inside(boundary, projectRoot)) throw new Error("Локальная конфигурация находится за пределами worktree");
|
|
93
|
+
return {
|
|
94
|
+
scope: "project", root: await canonical(join(projectRoot, ".opencode", "gotcha")),
|
|
95
|
+
projectRoot, reason: "local-plugin", source: origin.source ?? null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { scope: "global", root: join(globalDirectory, "gotcha"), projectRoot: null, reason: "global-plugin", source: origin.source ?? null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function createScopedStore({ storeOptions, ...options } = {}) {
|
|
102
|
+
const selected = await resolveScope(options);
|
|
103
|
+
const store = new GotchaStore(selected.root, { ...storeOptions, gitignore: selected.scope === "project" });
|
|
104
|
+
return { ...selected, store };
|
|
105
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readFile, writeFile, stat, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { descriptionFromValue, indexDate, keyError, parseIndex, rankEntries, renderIndex, usageCount } from "./markdown.js";
|
|
6
|
+
import { withStoreLock } from "./lock.js";
|
|
7
|
+
|
|
8
|
+
function today() {
|
|
9
|
+
return new Date().toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readOptional(file) {
|
|
13
|
+
try {
|
|
14
|
+
return await readFile(file, "utf8");
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code !== "ENOENT") throw error;
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function atomicWrite(file, content, assertOwned) {
|
|
22
|
+
const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
|
|
23
|
+
let mode;
|
|
24
|
+
try {
|
|
25
|
+
mode = (await stat(file)).mode;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code !== "ENOENT") throw error;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(temporary, content, { flag: "wx", mode });
|
|
31
|
+
assertOwned();
|
|
32
|
+
await rename(temporary, file);
|
|
33
|
+
} finally {
|
|
34
|
+
await rm(temporary, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class GotchaStore {
|
|
39
|
+
#cache = null;
|
|
40
|
+
|
|
41
|
+
constructor(root, { lockTimeoutMs = 15000, gitignore = false } = {}) {
|
|
42
|
+
if (typeof root !== "string" || !root.trim()) throw new TypeError("Нужен каталог хранилища");
|
|
43
|
+
if (!Number.isFinite(lockTimeoutMs) || lockTimeoutMs < 0) throw new TypeError("Некорректное время ожидания блокировки");
|
|
44
|
+
this.root = resolve(root.startsWith("~/") ? join(homedir(), root.slice(2)) : root);
|
|
45
|
+
this.indexPath = join(this.root, "MEMORY.md");
|
|
46
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
47
|
+
this.gitignore = gitignore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async index() {
|
|
51
|
+
try {
|
|
52
|
+
const info = await stat(this.indexPath);
|
|
53
|
+
const signature = `${info.ino}:${info.mtimeMs}:${info.size}`;
|
|
54
|
+
if (this.#cache?.signature === signature) return this.#cache.content;
|
|
55
|
+
const content = await readFile(this.indexPath, "utf8");
|
|
56
|
+
this.#cache = { signature, content };
|
|
57
|
+
return content;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code !== "ENOENT") throw error;
|
|
60
|
+
this.#cache = null;
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async list() {
|
|
66
|
+
return (await this.#loadEntries(await this.index())).filter((entry) => entry.present);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async peek(key) {
|
|
70
|
+
const file = await this.#findFile(key);
|
|
71
|
+
if (!file) return null;
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(file, "utf8");
|
|
74
|
+
const entry = (await this.#loadEntries(await this.index())).find((item) => item.filename === basename(file));
|
|
75
|
+
return entry ? { entry, content } : null;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code !== "ENOENT") throw error;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async save({ key, value, description }) {
|
|
83
|
+
const error = keyError(key);
|
|
84
|
+
if (error) throw new TypeError(error);
|
|
85
|
+
if (typeof value !== "string") throw new TypeError("Содержимое записи должно быть строкой");
|
|
86
|
+
if (description !== undefined && typeof description !== "string") throw new TypeError("Описание записи должно быть строкой");
|
|
87
|
+
return this.#mutate(async (assertOwned) => {
|
|
88
|
+
const index = await readOptional(this.indexPath);
|
|
89
|
+
const entries = await this.#loadEntries(index);
|
|
90
|
+
const existingFile = await this.#findFile(key);
|
|
91
|
+
const file = existingFile ?? join(this.root, `${key}.md`);
|
|
92
|
+
const filename = basename(file);
|
|
93
|
+
let entry = entries.find((item) => item.filename === filename || item.key === key);
|
|
94
|
+
if (!entry) {
|
|
95
|
+
entry = { key, filename, accessCount: 1, created: today(), lastUsed: today() };
|
|
96
|
+
entries.push(entry);
|
|
97
|
+
}
|
|
98
|
+
entry.filename = filename;
|
|
99
|
+
entry.created ||= today();
|
|
100
|
+
entry.updated = today();
|
|
101
|
+
entry.desc = description || descriptionFromValue(value);
|
|
102
|
+
entry.present = true;
|
|
103
|
+
await atomicWrite(file, value.endsWith("\n") ? value : `${value}\n`, assertOwned);
|
|
104
|
+
await this.#writeIndex(index, entries, assertOwned);
|
|
105
|
+
return { entry, isNew: !existingFile };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async read(key) {
|
|
110
|
+
if (!(await this.#findFile(key))) return null;
|
|
111
|
+
return this.#mutate(async (assertOwned) => {
|
|
112
|
+
const file = await this.#findFile(key);
|
|
113
|
+
if (!file) return null;
|
|
114
|
+
const content = await readFile(file, "utf8");
|
|
115
|
+
const index = await readOptional(this.indexPath);
|
|
116
|
+
const entries = await this.#loadEntries(index);
|
|
117
|
+
const entry = entries.find((item) => item.filename === basename(file));
|
|
118
|
+
entry.accessCount = Math.min(Number.MAX_SAFE_INTEGER, entry.accessCount + 1);
|
|
119
|
+
entry.lastUsed = today();
|
|
120
|
+
await this.#writeIndex(index, entries, assertOwned);
|
|
121
|
+
return { entry, content };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async forget(key) {
|
|
126
|
+
if (!(await this.#findFile(key))) return null;
|
|
127
|
+
return this.#mutate(async (assertOwned) => {
|
|
128
|
+
const file = await this.#findFile(key);
|
|
129
|
+
if (!file) return null;
|
|
130
|
+
const content = await readFile(file, "utf8");
|
|
131
|
+
const index = await readOptional(this.indexPath);
|
|
132
|
+
const entries = await this.#loadEntries(index);
|
|
133
|
+
const entry = entries.find((item) => item.filename === basename(file));
|
|
134
|
+
assertOwned();
|
|
135
|
+
await rm(file);
|
|
136
|
+
await rm(join(this.root, ".data", entry.key), { force: true });
|
|
137
|
+
await this.#writeIndex(index, entries.filter((item) => item !== entry), assertOwned);
|
|
138
|
+
return { entry, content };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #mutate(run) {
|
|
143
|
+
return withStoreLock(this.root, async (assertOwned) => {
|
|
144
|
+
if (this.gitignore) {
|
|
145
|
+
assertOwned();
|
|
146
|
+
try {
|
|
147
|
+
await writeFile(join(this.root, ".gitignore"), ".gotcha.lock/\n.gotcha.lock.*.tmp/\n.*.md.*.tmp\n", { flag: "wx" });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code !== "EEXIST") throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return run(assertOwned);
|
|
153
|
+
}, this.lockTimeoutMs);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #writeIndex(index, entries, assertOwned) {
|
|
157
|
+
await atomicWrite(this.indexPath, renderIndex(index, entries), assertOwned);
|
|
158
|
+
this.#cache = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async #filenames() {
|
|
162
|
+
try {
|
|
163
|
+
const entries = await readdir(this.root, { withFileTypes: true });
|
|
164
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code !== "ENOENT") throw error;
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async #findFile(key) {
|
|
172
|
+
if (keyError(key)) return null;
|
|
173
|
+
const names = (await this.#filenames()).filter((name) => name.endsWith(".md") && !keyError(basename(name, ".md")));
|
|
174
|
+
const filename = names.includes(`${key}.md`) ? `${key}.md` : null;
|
|
175
|
+
return filename ? join(this.root, filename) : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #loadEntries(index) {
|
|
179
|
+
const entries = new Map(parseIndex(index).map((entry) => [entry.key, entry]));
|
|
180
|
+
const indexedFilenames = new Set([...entries.values()].map((entry) => entry.filename));
|
|
181
|
+
const names = await this.#filenames();
|
|
182
|
+
for (const filename of names) {
|
|
183
|
+
const key = basename(filename, ".md");
|
|
184
|
+
if (!filename.endsWith(".md") || keyError(key) || indexedFilenames.has(filename)) continue;
|
|
185
|
+
const entry = entries.get(key);
|
|
186
|
+
try {
|
|
187
|
+
entries.set(key, entry
|
|
188
|
+
? { ...entry, filename }
|
|
189
|
+
: { key, filename, desc: descriptionFromValue(await readFile(join(this.root, filename), "utf8")) });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error.code !== "ENOENT") throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const files = new Set(names);
|
|
195
|
+
for (const entry of entries.values()) {
|
|
196
|
+
entry.present = files.has(entry.filename);
|
|
197
|
+
if (entry.accessCount !== undefined) continue;
|
|
198
|
+
const data = await readOptional(join(this.root, ".data", entry.key));
|
|
199
|
+
const fields = Object.fromEntries(data.split("\n").filter((line) => line.includes("=")).map((line) => {
|
|
200
|
+
const separator = line.indexOf("=");
|
|
201
|
+
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
202
|
+
}));
|
|
203
|
+
entry.accessCount = usageCount(fields.access_count);
|
|
204
|
+
entry.created = indexDate(fields.created);
|
|
205
|
+
entry.updated = indexDate(fields.updated);
|
|
206
|
+
entry.lastUsed = indexDate(fields.last_used);
|
|
207
|
+
}
|
|
208
|
+
return rankEntries([...entries.values()]);
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/tools-v1.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { createGotchaTools } from "./tools.js";
|
|
3
|
+
|
|
4
|
+
export function createGotchaToolsV1(getSelected) {
|
|
5
|
+
return Object.fromEntries(Object.entries(createGotchaTools(getSelected)).map(([name, definition]) => {
|
|
6
|
+
const args = Object.fromEntries(Object.entries(definition.input.properties).map(([field, schema]) => {
|
|
7
|
+
let value = tool.schema.string().trim().min(schema.minLength).describe(schema.description);
|
|
8
|
+
if (schema.maxLength) value = value.max(schema.maxLength);
|
|
9
|
+
if (schema.pattern) value = value.regex(new RegExp(schema.pattern));
|
|
10
|
+
if (!definition.input.required.includes(field)) value = value.optional();
|
|
11
|
+
return [field, value];
|
|
12
|
+
}));
|
|
13
|
+
return [name, tool({ description: definition.description, args, execute: definition.execute })];
|
|
14
|
+
}));
|
|
15
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { keyError } from "./markdown.js";
|
|
2
|
+
|
|
3
|
+
function text(description) {
|
|
4
|
+
return { type: "string", minLength: 1, description };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const key = {
|
|
8
|
+
...text("Стабильный ключ зацепки: латиница, цифры, _ или -, до 64 символов; MEMORY зарезервирован, префикс типа не требуется"),
|
|
9
|
+
maxLength: 64, pattern: "^(?![Mm][Ee][Mm][Oo][Rr][Yy]$)[A-Za-z0-9_-]+$",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function markdown(args) {
|
|
13
|
+
const sections = [
|
|
14
|
+
`# ${args.description}`,
|
|
15
|
+
`## Когда пригодится\n${args.task}\n\n${args.conditions}`,
|
|
16
|
+
];
|
|
17
|
+
if (args.failedAttempt) sections.push(`## Что не помогает\n${args.failedAttempt}`);
|
|
18
|
+
sections.push(
|
|
19
|
+
`## Рабочий приём\n${args.steps}`,
|
|
20
|
+
`## Успешный пример\n${args.example}`,
|
|
21
|
+
`## Как проверено\n${args.verification}`,
|
|
22
|
+
);
|
|
23
|
+
return `${sections.join("\n\n")}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function define(getSelected, description, properties, run, optional = []) {
|
|
27
|
+
const required = Object.keys(properties).filter((field) => !optional.includes(field));
|
|
28
|
+
return {
|
|
29
|
+
description,
|
|
30
|
+
input: { type: "object", properties, required, additionalProperties: false },
|
|
31
|
+
async execute(input = {}) {
|
|
32
|
+
const args = {};
|
|
33
|
+
const fields = [];
|
|
34
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) fields.push("args");
|
|
35
|
+
else for (const [field, schema] of Object.entries(properties)) {
|
|
36
|
+
if (input[field] === undefined && !required.includes(field)) continue;
|
|
37
|
+
const value = typeof input[field] === "string" ? input[field].trim() : undefined;
|
|
38
|
+
if (!value || schema.maxLength && value.length > schema.maxLength ||
|
|
39
|
+
schema.pattern && !new RegExp(schema.pattern).test(value) || field === "key" && keyError(value)) fields.push(field);
|
|
40
|
+
else args[field] = value;
|
|
41
|
+
}
|
|
42
|
+
if (fields.length) {
|
|
43
|
+
return JSON.stringify({ ok: false, error: `Некорректные параметры: ${fields.join(", ")}` });
|
|
44
|
+
}
|
|
45
|
+
let selected;
|
|
46
|
+
try {
|
|
47
|
+
selected = await getSelected();
|
|
48
|
+
return JSON.stringify({ ok: true, scope: selected.scope, root: selected.root, ...await run(args, selected.store) });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
ok: false, ...(selected ? { scope: selected.scope, root: selected.root } : {}),
|
|
52
|
+
error: error.message ?? String(error),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createGotchaTools(getSelected) {
|
|
60
|
+
return {
|
|
61
|
+
gotcha_save: define(getSelected,
|
|
62
|
+
"Сохранить или уточнить проверенный обход или приём повторяющейся задачи. Сначала проверь индекс на дубли. Нужны успешный пример и основание проверки; ошибка необязательна. Создание = 1 использование, обновление сохраняет счётчик.", {
|
|
63
|
+
key,
|
|
64
|
+
description: { ...text("Короткое однострочное описание: когда пригодится зацепка"), maxLength: 160, pattern: "^[^\\r\\n]+$" },
|
|
65
|
+
task: text("Повторяющаяся задача или узнаваемый симптом"),
|
|
66
|
+
conditions: text("Условия применимости: инструмент, окружение, версия или особенности проекта"),
|
|
67
|
+
steps: text("Конкретные действия и объяснение, какой поиск или какие итерации они экономят"),
|
|
68
|
+
example: text("Воспроизводимый успешный пример: исходные данные, команда или запрос и ожидаемый результат"),
|
|
69
|
+
verification: text("Фактический результат проверки, условия проверки и доступный источник"),
|
|
70
|
+
failedAttempt: text("Неудачный путь, если он действительно был и его легко повторить"),
|
|
71
|
+
}, async (args, store) => {
|
|
72
|
+
const result = await store.save({ key: args.key, description: args.description, value: markdown(args) });
|
|
73
|
+
return { key: result.entry.key, uses: result.entry.accessCount, action: result.isNew ? "created" : "updated" };
|
|
74
|
+
}, ["failedAttempt"]),
|
|
75
|
+
gotcha_read: define(getSelected, "Прочитать полную зацепку и увеличить счётчик использования на 1. Сверь условия и проверь результат приёма.", { key }, async (args, store) => {
|
|
76
|
+
const result = await store.read(args.key);
|
|
77
|
+
return result
|
|
78
|
+
? { key: result.entry.key, uses: result.entry.accessCount, content: result.content }
|
|
79
|
+
: { ok: false, key: args.key, error: "Зацепка не найдена" };
|
|
80
|
+
}),
|
|
81
|
+
gotcha_index: define(getSelected, "Показать ранжированный индекс и выбранное хранилище. Используй перед повторяющейся задачей или сохранением нового приёма. Счётчики не меняются.", {}, async (_args, store) => ({ index: await store.index() })),
|
|
82
|
+
gotcha_forget: define(getSelected, "Удалить утратившую смысл зацепку и её строку индекса. Возвращает удалённый текст.", { key }, async (args, store) => {
|
|
83
|
+
const result = await store.forget(args.key);
|
|
84
|
+
return result
|
|
85
|
+
? { key: result.entry.key, action: "deleted", content: result.content }
|
|
86
|
+
: { ok: false, key: args.key, error: "Зацепка не найдена" };
|
|
87
|
+
}),
|
|
88
|
+
gotcha_review: define(getSelected, "Показать записи по востребованности, описания и даты для пересмотра применимости. Популярность не доказывает успешность; просмотр не увеличивает счётчики.", {}, async (_args, store) => ({
|
|
89
|
+
entries: (await store.list()).map((entry) => ({
|
|
90
|
+
key: entry.key, description: entry.desc, uses: entry.accessCount,
|
|
91
|
+
created: entry.created, updated: entry.updated, lastUsed: entry.lastUsed,
|
|
92
|
+
})),
|
|
93
|
+
indexBytes: Buffer.byteLength(await store.index(), "utf8"),
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/v2/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createScopedStore } from "../src/scope.js";
|
|
2
|
+
import { createGotchaTools } from "../src/tools.js";
|
|
3
|
+
import { createContextReader } from "../src/context.js";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: "opencode-gotcha",
|
|
7
|
+
async setup(ctx) {
|
|
8
|
+
let selection;
|
|
9
|
+
const getSelected = () => {
|
|
10
|
+
selection ??= createScopedStore({
|
|
11
|
+
directory: ctx.location.directory,
|
|
12
|
+
worktree: ctx.location.project.directory,
|
|
13
|
+
origin: ctx.options.origin,
|
|
14
|
+
globalConfigDirectory: ctx.options.globalConfigDirectory,
|
|
15
|
+
}).catch((error) => {
|
|
16
|
+
selection = undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
return selection;
|
|
20
|
+
};
|
|
21
|
+
const context = createContextReader(getSelected, (error) => console.error(`opencode-gotcha: ${error.message}`));
|
|
22
|
+
const tools = createGotchaTools(getSelected);
|
|
23
|
+
await ctx.tool.transform((editor) => {
|
|
24
|
+
for (const [name, definition] of Object.entries(tools)) {
|
|
25
|
+
editor.add({
|
|
26
|
+
name, description: definition.description, input: definition.input,
|
|
27
|
+
options: { codemode: true },
|
|
28
|
+
execute: async (input) => ({ content: await definition.execute(input) }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
for (const kind of ["context", "compaction"]) {
|
|
33
|
+
await ctx.session.hook(kind, async (event) => {
|
|
34
|
+
event.system.push({ type: "text", text: await context() });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|