@snack-ai/opencode 1.0.0 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/plugin.js +44 -5
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.1",
4
4
  "description": "Fail-open OpenCode metadata capture for SNACK",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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}`,