@snack-ai/opencode 1.0.0 → 1.0.2

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 CHANGED
@@ -3,13 +3,9 @@
3
3
  Live metadata capture for [SNACK](https://github.com/Duck1201/snack) — an OpenCode plugin that
4
4
  records what happened to each prompt, and never what was in it.
5
5
 
6
- [English](#english) · [Português](#português)
6
+ Em português: [README.pt-BR.md](./README.pt-BR.md).
7
7
 
8
- ---
9
-
10
- ## English
11
-
12
- ### The friendly version
8
+ ## The friendly version
13
9
 
14
10
  OpenCode already writes down what you did. This plugin watches it happen instead of reading about it
15
11
  afterwards.
@@ -22,7 +18,7 @@ It is deliberately tiny. It appends one line of JSON per event to a private file
22
18
  way. It never opens a database, never imports the SNACK CLI, never phones anywhere, and never —
23
19
  under any failure — gets between you and your prompt.
24
20
 
25
- ### You do not install this yourself
21
+ ## You do not install this yourself
26
22
 
27
23
  The SNACK CLI registers it in OpenCode's own configuration for you:
28
24
 
@@ -38,7 +34,7 @@ you confirm. `snack doctor` afterwards tells you whether the registration is one
38
34
  default. The plugin is the upgrade: outcomes observed live, including the refusals that leave no
39
35
  trace, and optional prompt-size features that are computed in memory and thrown away.
40
36
 
41
- ### What it writes
37
+ ## What it writes
42
38
 
43
39
  One line of JSON per event, appended to a private spool directory that setup configured, under a
44
40
  versioned schema (`spool-event-v1`) that both packages ship a byte-identical copy of. Every field is
@@ -56,7 +52,7 @@ With `--enable-prospective-analysis`, each prompt additionally carries a few non
56
52
  features: an estimated token count, a bucketed line count, a bucketed count of fenced code blocks,
57
53
  and how many files were attached. They are derived in memory from text the plugin never writes down.
58
54
 
59
- ### What it will not do
55
+ ## What it will not do
60
56
 
61
57
  **It will not break OpenCode.** Capture failures are swallowed, never thrown into the host, and
62
58
  never allowed to block a prompt. If the spool cannot be written, the plugin warns at most once a
@@ -73,7 +69,7 @@ would carry an invented meaning downstream for as long as it lived.
73
69
 
74
70
  ---
75
71
 
76
- ### Under the hood
72
+ ## Under the hood
77
73
 
78
74
  #### The spool
79
75
 
@@ -110,7 +106,7 @@ The provider's own error **code** is stored on purpose — it is what distinguis
110
106
  a timeout, and classifying that difference correctly is the entire reason SNACK does not treat your
111
107
  flaky Wi-Fi as a quota event. The error _message_ is not stored.
112
108
 
113
- ### Compatibility
109
+ ## Compatibility
114
110
 
115
111
  Requires Node.js 24 and a `@snack-ai/cli` that accepts `spool-event-v1`. Event `schema_version` is
116
112
  `1` and has been stable since the plugin's first release, so a current CLI reads any published
@@ -119,122 +115,3 @@ rather than incompatible, and re-running `snack setup opencode --install-plugin`
119
115
 
120
116
  Apache-2.0. Security reports go through the private channel in
121
117
  [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.
231
-
232
- ### Compatibilidade
233
-
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.
238
-
239
- Apache-2.0. Relatos de segurança vão pelo canal privado descrito em
240
- [SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
@@ -0,0 +1,121 @@
1
+ # @snack-ai/opencode
2
+
3
+ Captura de metadados ao vivo para o [SNACK](https://github.com/Duck1201/snack) — um plugin do
4
+ OpenCode que registra o que aconteceu com cada prompt, e nunca o que havia nele.
5
+
6
+ In English: [README.md](./README.md).
7
+
8
+ ## A versão amigável
9
+
10
+ O OpenCode já anota o que você fez. Este plugin assiste isso acontecer, em vez de ler sobre depois.
11
+
12
+ Essa diferença importa mais do que parece. Algumas coisas simplesmente não sobrevivem até o disco —
13
+ um prompt que o provedor recusa de cara pode não deixar rastro durável no banco do próprio OpenCode,
14
+ e uma recusa que o SNACK não enxerga é uma recusa com a qual ele não aprende. O plugin pega essas no
15
+ ato.
16
+
17
+ Ele é propositalmente minúsculo. Acrescenta uma linha de JSON por evento num arquivo privado e sai
18
+ da frente. Nunca abre banco, nunca importa a CLI do SNACK, nunca liga para lugar nenhum, e nunca —
19
+ sob falha alguma — entra entre você e o seu prompt.
20
+
21
+ ## Você não instala isto por conta própria
22
+
23
+ A CLI do SNACK registra o plugin na configuração do próprio OpenCode para você:
24
+
25
+ ```bash
26
+ npm install -g @snack-ai/cli
27
+ snack setup opencode --install-plugin
28
+ ```
29
+
30
+ O setup mostra a mudança exata de configuração antes de fazê-la, guarda um backup, e não faz nada
31
+ até você confirmar. Depois, `snack doctor` diz se o registro é um que o SNACK consegue ler.
32
+
33
+ **O SNACK funciona bem sem ele.** A CLI lê o banco do OpenCode diretamente, e esse é o padrão. O
34
+ plugin é o upgrade: desfechos observados ao vivo, incluindo as recusas que não deixam rastro, e
35
+ features opcionais de tamanho de prompt calculadas em memória e descartadas.
36
+
37
+ ## O que ele escreve
38
+
39
+ Uma linha de JSON por evento, acrescentada a um diretório de spool privado que o setup configurou,
40
+ sob um schema versionado (`spool-event-v1`) do qual os dois pacotes carregam uma cópia
41
+ byte-idêntica. Todo campo é metadado:
42
+
43
+ - a qual prompt e sessão pertence, por identificador;
44
+ - o provedor e o modelo, e quando aconteceu;
45
+ - como terminou: completado, cancelado, um erro operacional, ou uma restrição observada e sua
46
+ classe;
47
+ - contagens de tokens e custo, como o provedor reportou.
48
+
49
+ Não existe campo para texto de prompt nem de resposta, e o schema recusa campos desconhecidos de
50
+ forma categórica.
51
+
52
+ Com `--enable-prospective-analysis`, cada prompt carrega também algumas features não semânticas de
53
+ formato: contagem estimada de tokens, contagem de linhas em faixas, contagem de blocos de código em
54
+ faixas, e quantos arquivos foram anexados. São derivadas em memória de um texto que o plugin nunca
55
+ escreve.
56
+
57
+ ## O que ele não vai fazer
58
+
59
+ **Não vai quebrar o OpenCode.** Falhas de captura são engolidas, nunca lançadas para dentro do host,
60
+ e nunca podem bloquear um prompt. Se o spool não puder ser escrito, o plugin avisa no máximo uma vez
61
+ por minuto e o OpenCode segue exatamente como se ele não estivesse instalado. Isso não é gentileza
62
+ de melhor-esforço; é a primeira restrição de projeto do plugin, e é testada injetando falha no
63
+ caminho de escrita e verificando que o host nunca vê exceção.
64
+
65
+ **Não vai ler o que não precisa.** Nunca abre SQLite, nunca importa a CLI do SNACK, nunca toca nas
66
+ credenciais do OpenCode. Escreve no seu próprio diretório de spool e em nenhum outro lugar.
67
+
68
+ **Não vai chutar.** Um evento que não valida contra o schema publicado é descartado em vez de
69
+ interpretado pela metade, porque um registro canônico construído a partir de um formato que o SNACK
70
+ não reconhece carregaria um significado inventado rio abaixo por todo o tempo em que existisse.
71
+
72
+ ---
73
+
74
+ ## Por dentro
75
+
76
+ #### O spool
77
+
78
+ Eventos são acrescentados como NDJSON em arquivos de segmento com permissão `0600` num diretório
79
+ `0700`. Acrescentar é a única operação de escrita; nada é reescrito no lugar, e é isso que torna uma
80
+ queda no meio da escrita recuperável em vez de corruptora.
81
+
82
+ Uma linha cortada por uma queda é exatamente o que a recuperação de truncamento espera: o leitor
83
+ valida cada linha, descarta a incompleta com um diagnóstico sanitizado, e mantém tudo que veio
84
+ antes. A contagem de registros recusados aparece no `snack sync` como `rejected_invalid` em vez de
85
+ sumir.
86
+
87
+ Segmentos só são removidos depois que **toda fonte configurada commitou além deles**. Um cursor que
88
+ avançasse sem sua transação commitar descartaria histórico em silêncio, então cursores só se movem
89
+ dentro da transação que commita.
90
+
91
+ #### Reconciliação com o backfill
92
+
93
+ O plugin e o leitor de banco vão ver a maioria dos prompts. Esse é o arranjo pretendido, não um bug
94
+ a evitar, e o SNACK reconcilia os dois num único registro canônico por identidade estável, domínio
95
+ de revisão e finalidade — nunca confiando em quem chegou primeiro.
96
+
97
+ Restrições são unidas entre os dois caminhos: uma recusa vista por qualquer observador conta.
98
+ Revisões finais conflitantes que não podem ser ordenadas são excluídas em vez de resolvidas no
99
+ chute. Testes de propriedade garantem convergência sob duplicatas, reordenação e lacunas, porque
100
+ "eventualmente consistente" sem prova é só esperança.
101
+
102
+ #### Livre de conteúdo por construção
103
+
104
+ O schema não tem campo capaz de carregar texto de prompt ou resposta, então vazamento é violação de
105
+ schema, não falha de política. As strings-canário de privacidade são compartilhadas byte a byte
106
+ entre os dois pacotes e empurradas pelo caminho de captura nos testes; um canário chegando a
107
+ qualquer byte escrito quebra o build.
108
+
109
+ O **código** de erro do provedor é armazenado de propósito — é o que distingue um rate limit de um
110
+ timeout, e classificar essa diferença corretamente é a razão inteira de o SNACK não tratar o seu
111
+ Wi-Fi instável como evento de quota. A _mensagem_ de erro não é armazenada.
112
+
113
+ ## Compatibilidade
114
+
115
+ Requer Node.js 24 e uma `@snack-ai/cli` que aceite `spool-event-v1`. O `schema_version` do evento é
116
+ `1` e está estável desde a primeira release do plugin, então uma CLI atual lê qualquer versão
117
+ publicada dele. O `snack doctor` reporta um registro fixado numa versão antiga como desatualizado, e
118
+ não como incompatível; rodar `snack setup opencode --install-plugin` de novo atualiza o pin.
119
+
120
+ Apache-2.0. Relatos de segurança vão pelo canal privado descrito em
121
+ [SECURITY.md](https://github.com/Duck1201/snack/blob/main/SECURITY.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snack-ai/opencode",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Fail-open OpenCode metadata capture for SNACK",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -17,7 +17,8 @@
17
17
  "src",
18
18
  "LICENSE",
19
19
  "NOTICE",
20
- "README.md"
20
+ "README.md",
21
+ "README.pt-BR.md"
21
22
  ],
22
23
  "engines": {
23
24
  "node": ">=24 <25"
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
- prompts.set(sessionId, {
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
- append(targetDirectory, {
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}`,