@snack-ai/opencode 0.1.3 → 1.0.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/README.md +197 -19
- package/package.json +1 -1
- package/src/plugin.js +44 -5
package/README.md
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
# @snack-ai/opencode
|
|
2
2
|
|
|
3
|
-
Live metadata capture for [SNACK](https://github.com/Duck1201/snack)
|
|
4
|
-
records what happened to each prompt
|
|
3
|
+
Live metadata capture for [SNACK](https://github.com/Duck1201/snack) — an OpenCode plugin that
|
|
4
|
+
records what happened to each prompt, and never what was in it.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
[English](#english) · [Português](#português)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## English
|
|
11
|
+
|
|
12
|
+
### The friendly version
|
|
13
|
+
|
|
14
|
+
OpenCode already writes down what you did. This plugin watches it happen instead of reading about it
|
|
15
|
+
afterwards.
|
|
16
|
+
|
|
17
|
+
That difference matters more than it sounds. Some things simply do not survive to disk — a prompt
|
|
18
|
+
the provider refuses outright can leave no durable trace in OpenCode's own database, and a refusal
|
|
19
|
+
SNACK cannot see is a refusal it cannot learn from. The plugin catches those as they happen.
|
|
20
|
+
|
|
21
|
+
It is deliberately tiny. It appends one line of JSON per event to a private file and gets out of the
|
|
22
|
+
way. It never opens a database, never imports the SNACK CLI, never phones anywhere, and never —
|
|
23
|
+
under any failure — gets between you and your prompt.
|
|
24
|
+
|
|
25
|
+
### You do not install this yourself
|
|
26
|
+
|
|
27
|
+
The SNACK CLI registers it in OpenCode's own configuration for you:
|
|
9
28
|
|
|
10
29
|
```bash
|
|
11
30
|
npm install -g @snack-ai/cli
|
|
@@ -13,14 +32,13 @@ snack setup opencode --install-plugin
|
|
|
13
32
|
```
|
|
14
33
|
|
|
15
34
|
Setup shows the exact configuration change before making it, keeps a backup, and does nothing until
|
|
16
|
-
you confirm. `snack doctor` afterwards
|
|
35
|
+
you confirm. `snack doctor` afterwards tells you whether the registration is one SNACK can read.
|
|
17
36
|
|
|
18
|
-
SNACK works without
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
than stored.
|
|
37
|
+
**SNACK works fine without it.** The CLI can read OpenCode's database directly, and that is the
|
|
38
|
+
default. The plugin is the upgrade: outcomes observed live, including the refusals that leave no
|
|
39
|
+
trace, and optional prompt-size features that are computed in memory and thrown away.
|
|
22
40
|
|
|
23
|
-
|
|
41
|
+
### What it writes
|
|
24
42
|
|
|
25
43
|
One line of JSON per event, appended to a private spool directory that setup configured, under a
|
|
26
44
|
versioned schema (`spool-event-v1`) that both packages ship a byte-identical copy of. Every field is
|
|
@@ -38,25 +56,185 @@ With `--enable-prospective-analysis`, each prompt additionally carries a few non
|
|
|
38
56
|
features: an estimated token count, a bucketed line count, a bucketed count of fenced code blocks,
|
|
39
57
|
and how many files were attached. They are derived in memory from text the plugin never writes down.
|
|
40
58
|
|
|
41
|
-
|
|
59
|
+
### What it will not do
|
|
42
60
|
|
|
43
61
|
**It will not break OpenCode.** Capture failures are swallowed, never thrown into the host, and
|
|
44
62
|
never allowed to block a prompt. If the spool cannot be written, the plugin warns at most once a
|
|
45
|
-
minute and OpenCode carries on as
|
|
63
|
+
minute and OpenCode carries on exactly as if it were not installed. This is not best-effort
|
|
64
|
+
politeness; it is the plugin's first design constraint, and it is tested by faulting the write path
|
|
65
|
+
and asserting the host never sees an exception.
|
|
46
66
|
|
|
47
67
|
**It will not read what it does not need.** It never opens SQLite, never imports the SNACK CLI, and
|
|
48
68
|
never touches OpenCode's credentials. It writes to its own spool directory and nowhere else.
|
|
49
69
|
|
|
50
70
|
**It will not guess.** An event that does not validate against the shipped schema is dropped rather
|
|
51
71
|
than partially interpreted, because a canonical record built from a shape SNACK does not recognize
|
|
52
|
-
would carry an invented meaning downstream.
|
|
72
|
+
would carry an invented meaning downstream for as long as it lived.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### Under the hood
|
|
77
|
+
|
|
78
|
+
#### The spool
|
|
79
|
+
|
|
80
|
+
Events are appended as NDJSON to segment files with `0600` permissions in a `0700` directory. Append
|
|
81
|
+
is the only write operation; nothing is ever rewritten in place, which is what makes a crash
|
|
82
|
+
mid-write recoverable rather than corrupting.
|
|
83
|
+
|
|
84
|
+
A line cut short by a crash is exactly what truncation recovery expects: the reader validates each
|
|
85
|
+
line, discards the incomplete one with a sanitized diagnostic, and keeps everything before it. The
|
|
86
|
+
count of refused records surfaces in `snack sync` as `rejected_invalid` rather than disappearing.
|
|
87
|
+
|
|
88
|
+
Segments are removed only after **every configured source has committed past them**. A cursor that
|
|
89
|
+
advanced without its transaction committing would silently drop history, so cursors move only inside
|
|
90
|
+
the committing transaction.
|
|
91
|
+
|
|
92
|
+
#### Reconciliation with backfill
|
|
93
|
+
|
|
94
|
+
The plugin and the database reader will both see most prompts. That is the intended arrangement, not
|
|
95
|
+
a bug to avoid, and SNACK reconciles the two into one canonical record by stable identity, revision
|
|
96
|
+
domain, and finality — never by trusting whichever arrived first.
|
|
97
|
+
|
|
98
|
+
Restrictions are unioned across both paths: a refusal seen by either observer counts. Conflicting
|
|
99
|
+
final revisions that cannot be ordered are excluded rather than resolved by guesswork. Property
|
|
100
|
+
tests assert convergence under duplicates, reordering, and gaps, because "eventually consistent"
|
|
101
|
+
without a proof is just hope.
|
|
102
|
+
|
|
103
|
+
#### Content-free by construction
|
|
104
|
+
|
|
105
|
+
The schema has no field that could carry prompt or response text, so leakage is a schema violation
|
|
106
|
+
rather than a policy failure. The privacy canaries are shared byte-identically between both packages
|
|
107
|
+
and driven through the capture path in tests; a canary reaching any written byte fails the build.
|
|
108
|
+
|
|
109
|
+
The provider's own error **code** is stored on purpose — it is what distinguishes a rate limit from
|
|
110
|
+
a timeout, and classifying that difference correctly is the entire reason SNACK does not treat your
|
|
111
|
+
flaky Wi-Fi as a quota event. The error _message_ is not stored.
|
|
112
|
+
|
|
113
|
+
### Compatibility
|
|
114
|
+
|
|
115
|
+
Requires Node.js 24 and a `@snack-ai/cli` that accepts `spool-event-v1`. Event `schema_version` is
|
|
116
|
+
`1` and has been stable since the plugin's first release, so a current CLI reads any published
|
|
117
|
+
version of this plugin. `snack doctor` reports a registration pinned at an older version as outdated
|
|
118
|
+
rather than incompatible, and re-running `snack setup opencode --install-plugin` updates the pin.
|
|
119
|
+
|
|
120
|
+
Apache-2.0. Security reports go through the private channel in
|
|
121
|
+
[SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Português
|
|
126
|
+
|
|
127
|
+
### A versão amigável
|
|
128
|
+
|
|
129
|
+
O OpenCode já anota o que você fez. Este plugin assiste isso acontecer, em vez de ler sobre depois.
|
|
130
|
+
|
|
131
|
+
Essa diferença importa mais do que parece. Algumas coisas simplesmente não sobrevivem até o disco —
|
|
132
|
+
um prompt que o provedor recusa de cara pode não deixar rastro durável no banco do próprio OpenCode,
|
|
133
|
+
e uma recusa que o SNACK não enxerga é uma recusa com a qual ele não aprende. O plugin pega essas no
|
|
134
|
+
ato.
|
|
135
|
+
|
|
136
|
+
Ele é propositalmente minúsculo. Acrescenta uma linha de JSON por evento num arquivo privado e sai
|
|
137
|
+
da frente. Nunca abre banco, nunca importa a CLI do SNACK, nunca liga para lugar nenhum, e nunca —
|
|
138
|
+
sob falha alguma — entra entre você e o seu prompt.
|
|
139
|
+
|
|
140
|
+
### Você não instala isto por conta própria
|
|
141
|
+
|
|
142
|
+
A CLI do SNACK registra o plugin na configuração do próprio OpenCode para você:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm install -g @snack-ai/cli
|
|
146
|
+
snack setup opencode --install-plugin
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
O setup mostra a mudança exata de configuração antes de fazê-la, guarda um backup, e não faz nada
|
|
150
|
+
até você confirmar. Depois, `snack doctor` diz se o registro é um que o SNACK consegue ler.
|
|
151
|
+
|
|
152
|
+
**O SNACK funciona bem sem ele.** A CLI lê o banco do OpenCode diretamente, e esse é o padrão. O
|
|
153
|
+
plugin é o upgrade: desfechos observados ao vivo, incluindo as recusas que não deixam rastro, e
|
|
154
|
+
features opcionais de tamanho de prompt calculadas em memória e descartadas.
|
|
155
|
+
|
|
156
|
+
### O que ele escreve
|
|
157
|
+
|
|
158
|
+
Uma linha de JSON por evento, acrescentada a um diretório de spool privado que o setup configurou,
|
|
159
|
+
sob um schema versionado (`spool-event-v1`) do qual os dois pacotes carregam uma cópia
|
|
160
|
+
byte-idêntica. Todo campo é metadado:
|
|
161
|
+
|
|
162
|
+
- a qual prompt e sessão pertence, por identificador;
|
|
163
|
+
- o provedor e o modelo, e quando aconteceu;
|
|
164
|
+
- como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua
|
|
165
|
+
classe;
|
|
166
|
+
- contagens de tokens e custo, como o provedor reportou.
|
|
167
|
+
|
|
168
|
+
Não existe campo para texto de prompt nem de resposta, e o schema recusa campos desconhecidos de
|
|
169
|
+
forma categórica.
|
|
170
|
+
|
|
171
|
+
Com `--enable-prospective-analysis`, cada prompt carrega também algumas features não semânticas de
|
|
172
|
+
formato: contagem estimada de tokens, contagem de linhas em faixas, contagem de blocos de código em
|
|
173
|
+
faixas, e quantos arquivos foram anexados. São derivadas em memória de um texto que o plugin nunca
|
|
174
|
+
escreve.
|
|
175
|
+
|
|
176
|
+
### O que ele não vai fazer
|
|
177
|
+
|
|
178
|
+
**Não vai quebrar o OpenCode.** Falhas de captura são engolidas, nunca lançadas para dentro do host,
|
|
179
|
+
e nunca podem bloquear um prompt. Se o spool não puder ser escrito, o plugin avisa no máximo uma vez
|
|
180
|
+
por minuto e o OpenCode segue exatamente como se ele não estivesse instalado. Isso não é gentileza
|
|
181
|
+
de melhor-esforço; é a primeira restrição de projeto do plugin, e é testada injetando falha no
|
|
182
|
+
caminho de escrita e verificando que o host nunca vê exceção.
|
|
183
|
+
|
|
184
|
+
**Não vai ler o que não precisa.** Nunca abre SQLite, nunca importa a CLI do SNACK, nunca toca nas
|
|
185
|
+
credenciais do OpenCode. Escreve no seu próprio diretório de spool e em nenhum outro lugar.
|
|
186
|
+
|
|
187
|
+
**Não vai chutar.** Um evento que não valida contra o schema publicado é descartado em vez de
|
|
188
|
+
interpretado pela metade, porque um registro canônico construído a partir de um formato que o SNACK
|
|
189
|
+
não reconhece carregaria um significado inventado rio abaixo por todo o tempo em que existisse.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
### Por dentro
|
|
194
|
+
|
|
195
|
+
#### O spool
|
|
196
|
+
|
|
197
|
+
Eventos são acrescentados como NDJSON em arquivos de segmento com permissão `0600` num diretório
|
|
198
|
+
`0700`. Acrescentar é a única operação de escrita; nada é reescrito no lugar, e é isso que torna uma
|
|
199
|
+
queda no meio da escrita recuperável em vez de corruptora.
|
|
200
|
+
|
|
201
|
+
Uma linha cortada por uma queda é exatamente o que a recuperação de truncamento espera: o leitor
|
|
202
|
+
valida cada linha, descarta a incompleta com um diagnóstico sanitizado, e mantém tudo que veio
|
|
203
|
+
antes. A contagem de registros recusados aparece no `snack sync` como `rejected_invalid` em vez de
|
|
204
|
+
sumir.
|
|
205
|
+
|
|
206
|
+
Segmentos só são removidos depois que **toda fonte configurada commitou além deles**. Um cursor que
|
|
207
|
+
avançasse sem sua transação commitar descartaria histórico em silêncio, então cursores só se movem
|
|
208
|
+
dentro da transação que commita.
|
|
209
|
+
|
|
210
|
+
#### Reconciliação com o backfill
|
|
211
|
+
|
|
212
|
+
O plugin e o leitor de banco vão ver a maioria dos prompts. Esse é o arranjo pretendido, não um bug
|
|
213
|
+
a evitar, e o SNACK reconcilia os dois num único registro canônico por identidade estável, domínio
|
|
214
|
+
de revisão e finalidade — nunca confiando em quem chegou primeiro.
|
|
215
|
+
|
|
216
|
+
Restrições são unidas entre os dois caminhos: uma recusa vista por qualquer observador conta.
|
|
217
|
+
Revisões finais conflitantes que não podem ser ordenadas são excluídas em vez de resolvidas no
|
|
218
|
+
chute. Testes de propriedade garantem convergência sob duplicatas, reordenação e lacunas, porque
|
|
219
|
+
"eventualmente consistente" sem prova é só esperança.
|
|
220
|
+
|
|
221
|
+
#### Livre de conteúdo por construção
|
|
222
|
+
|
|
223
|
+
O schema não tem campo capaz de carregar texto de prompt ou resposta, então vazamento é violação de
|
|
224
|
+
schema, não falha de política. As strings-canário de privacidade são compartilhadas byte a byte
|
|
225
|
+
entre os dois pacotes e empurradas pelo caminho de captura nos testes; um canário chegando a
|
|
226
|
+
qualquer byte escrito quebra o build.
|
|
227
|
+
|
|
228
|
+
O **código** de erro do provedor é armazenado de propósito — é o que distingue um rate limit de um
|
|
229
|
+
timeout, e classificar essa diferença corretamente é a razão inteira de o SNACK não tratar o seu
|
|
230
|
+
Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada.
|
|
53
231
|
|
|
54
|
-
|
|
232
|
+
### Compatibilidade
|
|
55
233
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`snack setup opencode --install-plugin`
|
|
234
|
+
Requer Node.js 24 e uma `@snack-ai/cli` que aceite `spool-event-v1`. O `schema_version` do evento é
|
|
235
|
+
`1` e está estável desde a primeira release do plugin, então uma CLI atual lê qualquer versão
|
|
236
|
+
publicada dele. O `snack doctor` reporta um registro fixado numa versão antiga como desatualizado, e
|
|
237
|
+
não como incompatível; rodar `snack setup opencode --install-plugin` de novo atualiza o pin.
|
|
60
238
|
|
|
61
|
-
Apache-2.0.
|
|
239
|
+
Apache-2.0. Relatos de segurança vão pelo canal privado descrito em
|
|
62
240
|
[SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
|
package/package.json
CHANGED
package/src/plugin.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
19
19
|
const spoolDirectory = stringOrNull(options.spool_directory);
|
|
20
20
|
const captureFeatures = options.prospective_analysis === true;
|
|
21
21
|
const sourceBindings = bindingMap(options.source_bindings);
|
|
22
|
-
/** @type {Map<string, {promptId: string, provider: string | null, model: string | null, spoolDirectory: string}>} */
|
|
22
|
+
/** @type {Map<string, {promptId: string, provider: string | null, model: string | null, spoolDirectory: string, buffered: Record<string, unknown>[]}>} */
|
|
23
23
|
const prompts = new Map();
|
|
24
24
|
let writes = Promise.resolve();
|
|
25
25
|
let pendingWrites = 0;
|
|
@@ -56,10 +56,44 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
56
56
|
});
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Route a session's spool directory once its provider is known.
|
|
61
|
+
*
|
|
62
|
+
* OpenCode declares `model` optional on `chat.message` and does not send it on `1.18.10`, so the
|
|
63
|
+
* routing decision cannot be taken there. An event written to `_pending` is never attributed and
|
|
64
|
+
* never revisited, so a prompt whose provider is still unknown is held rather than misfiled, and
|
|
65
|
+
* released as soon as `chat.params` names one. A prompt whose provider never arrives is released
|
|
66
|
+
* to `_pending` at the terminal event, which is where it would have gone anyway.
|
|
67
|
+
*
|
|
68
|
+
* @param {{provider: string | null, model: string | null, spoolDirectory: string, buffered: Record<string, unknown>[]}} prompt
|
|
69
|
+
*/
|
|
70
|
+
const release = (prompt) => {
|
|
71
|
+
for (const event of prompt.buffered.splice(0)) {
|
|
72
|
+
append(prompt.spoolDirectory, { ...event, provider: prompt.provider, model: prompt.model });
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
59
76
|
return {
|
|
60
77
|
async dispose() {
|
|
78
|
+
for (const prompt of prompts.values()) release(prompt);
|
|
61
79
|
await writes;
|
|
62
80
|
},
|
|
81
|
+
async "chat.params"(/** @type {Record<string, unknown>} */ input) {
|
|
82
|
+
try {
|
|
83
|
+
const sessionId = stringOrNull(input.sessionID);
|
|
84
|
+
const prompt = sessionId ? prompts.get(sessionId) : undefined;
|
|
85
|
+
if (!prompt || prompt.provider || !spoolDirectory) return;
|
|
86
|
+
const model = recordOrNull(input.model);
|
|
87
|
+
const provider = stringOrNull(model?.providerID);
|
|
88
|
+
if (!provider) return;
|
|
89
|
+
prompt.provider = provider;
|
|
90
|
+
prompt.model = stringOrNull(model?.modelID);
|
|
91
|
+
prompt.spoolDirectory = sourceBindings.get(provider) ?? join(spoolDirectory, "_pending");
|
|
92
|
+
release(prompt);
|
|
93
|
+
} catch {
|
|
94
|
+
// Capture must never change OpenCode prompt behavior.
|
|
95
|
+
}
|
|
96
|
+
},
|
|
63
97
|
async "chat.message"(
|
|
64
98
|
/** @type {Record<string, unknown>} */ input,
|
|
65
99
|
/** @type {unknown} */ output,
|
|
@@ -76,14 +110,16 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
76
110
|
? (sourceBindings.get(provider) ?? join(spoolDirectory, "_pending"))
|
|
77
111
|
: join(spoolDirectory, "_pending");
|
|
78
112
|
if (!targetDirectory) return;
|
|
79
|
-
|
|
113
|
+
const prompt = {
|
|
80
114
|
promptId,
|
|
81
115
|
provider,
|
|
82
116
|
model: modelId,
|
|
83
117
|
spoolDirectory: targetDirectory,
|
|
84
|
-
|
|
118
|
+
/** @type {Record<string, unknown>[]} */ buffered: [],
|
|
119
|
+
};
|
|
120
|
+
prompts.set(sessionId, prompt);
|
|
85
121
|
const occurredAt = new Date().toISOString();
|
|
86
|
-
|
|
122
|
+
const started = {
|
|
87
123
|
schema_version: 1,
|
|
88
124
|
event_id: `chat.message:${sessionId}:${promptId}:${occurredAt}`,
|
|
89
125
|
installation_id: installationId,
|
|
@@ -101,7 +137,9 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
101
137
|
usage_slices: [],
|
|
102
138
|
restrictions: [],
|
|
103
139
|
...(captureFeatures ? { input_features: analyzePrompt(output) } : {}),
|
|
104
|
-
}
|
|
140
|
+
};
|
|
141
|
+
if (provider) append(targetDirectory, started);
|
|
142
|
+
else prompt.buffered.push(started);
|
|
105
143
|
} catch {
|
|
106
144
|
// Capture must never change OpenCode prompt behavior.
|
|
107
145
|
}
|
|
@@ -118,6 +156,7 @@ export async function SnackOpenCodePlugin(_context, options = {}) {
|
|
|
118
156
|
const occurredAt = timestampOrNow(properties.time);
|
|
119
157
|
const error = recordOrNull(properties.error);
|
|
120
158
|
const restricted = type === "session.error" && isExplicitRateLimit(error);
|
|
159
|
+
release(prompt);
|
|
121
160
|
append(prompt.spoolDirectory, {
|
|
122
161
|
schema_version: 1,
|
|
123
162
|
event_id: `${type}:${sessionId}:${prompt.promptId}:${occurredAt}`,
|