@tanstack/ai-code-mode-snippets 0.3.14

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/dist/esm/code-mode-with-snippets.d.ts +65 -0
  4. package/dist/esm/code-mode-with-snippets.js +145 -0
  5. package/dist/esm/code-mode-with-snippets.js.map +1 -0
  6. package/dist/esm/create-snippet-management-tools.d.ts +40 -0
  7. package/dist/esm/create-snippet-management-tools.js +173 -0
  8. package/dist/esm/create-snippet-management-tools.js.map +1 -0
  9. package/dist/esm/create-snippets-system-prompt.d.ts +22 -0
  10. package/dist/esm/create-snippets-system-prompt.js +234 -0
  11. package/dist/esm/create-snippets-system-prompt.js.map +1 -0
  12. package/dist/esm/generate-snippet-types.d.ts +7 -0
  13. package/dist/esm/generate-snippet-types.js +79 -0
  14. package/dist/esm/generate-snippet-types.js.map +1 -0
  15. package/dist/esm/index.d.ts +14 -0
  16. package/dist/esm/index.js +10 -0
  17. package/dist/esm/select-relevant-snippets.d.ts +29 -0
  18. package/dist/esm/select-relevant-snippets.js +56 -0
  19. package/dist/esm/select-relevant-snippets.js.map +1 -0
  20. package/dist/esm/snippets-to-bindings.d.ts +34 -0
  21. package/dist/esm/snippets-to-bindings.js +74 -0
  22. package/dist/esm/snippets-to-bindings.js.map +1 -0
  23. package/dist/esm/snippets-to-tools.d.ts +74 -0
  24. package/dist/esm/snippets-to-tools.js +147 -0
  25. package/dist/esm/snippets-to-tools.js.map +1 -0
  26. package/dist/esm/storage/file-storage.d.ts +27 -0
  27. package/dist/esm/storage/file-storage.js +155 -0
  28. package/dist/esm/storage/file-storage.js.map +1 -0
  29. package/dist/esm/storage/index.d.ts +3 -0
  30. package/dist/esm/storage/index.js +3 -0
  31. package/dist/esm/storage/memory-storage.d.ts +17 -0
  32. package/dist/esm/storage/memory-storage.js +100 -0
  33. package/dist/esm/storage/memory-storage.js.map +1 -0
  34. package/dist/esm/trust-strategies.d.ts +50 -0
  35. package/dist/esm/trust-strategies.js +73 -0
  36. package/dist/esm/trust-strategies.js.map +1 -0
  37. package/dist/esm/types.d.ts +216 -0
  38. package/package.json +92 -0
  39. package/src/code-mode-with-snippets.ts +210 -0
  40. package/src/create-snippet-management-tools.ts +298 -0
  41. package/src/create-snippets-system-prompt.ts +289 -0
  42. package/src/generate-snippet-types.ts +162 -0
  43. package/src/index.ts +60 -0
  44. package/src/select-relevant-snippets.ts +136 -0
  45. package/src/snippets-to-bindings.ts +135 -0
  46. package/src/snippets-to-tools.ts +325 -0
  47. package/src/storage/file-storage.ts +275 -0
  48. package/src/storage/index.ts +6 -0
  49. package/src/storage/memory-storage.ts +172 -0
  50. package/src/trust-strategies.ts +142 -0
  51. package/src/types.ts +289 -0
@@ -0,0 +1,325 @@
1
+ import { toolDefinition } from '@tanstack/ai'
2
+ import {
3
+ createEventAwareBindings,
4
+ stripTypeScript,
5
+ toolsToBindings,
6
+ } from '@tanstack/ai-code-mode'
7
+ import { z } from 'zod'
8
+ import type {
9
+ SchemaInput,
10
+ ServerTool,
11
+ ToolExecutionContext,
12
+ } from '@tanstack/ai'
13
+ import type {
14
+ CodeModeTool,
15
+ IsolateDriver,
16
+ ToolBinding,
17
+ } from '@tanstack/ai-code-mode'
18
+ import type { Snippet, SnippetStorage } from './types'
19
+
20
+ /**
21
+ * Options for converting a single snippet to a tool
22
+ */
23
+ export interface SnippetToToolOptions {
24
+ /**
25
+ * The snippet to convert
26
+ */
27
+ snippet: Snippet
28
+
29
+ /**
30
+ * Isolate driver for executing snippet code
31
+ */
32
+ driver: IsolateDriver
33
+
34
+ /**
35
+ * Pre-computed bindings for external_* functions
36
+ */
37
+ bindings: Record<string, ToolBinding>
38
+
39
+ /**
40
+ * Storage for updating execution stats
41
+ */
42
+ storage: SnippetStorage
43
+
44
+ /**
45
+ * Timeout for snippet execution in ms
46
+ * @default 30000
47
+ */
48
+ timeout?: number
49
+
50
+ /**
51
+ * Memory limit in bytes
52
+ * @default 128
53
+ */
54
+ memoryLimit?: number
55
+ }
56
+
57
+ interface SnippetsToToolsOptions {
58
+ /**
59
+ * Snippets to convert to tools
60
+ */
61
+ snippets: Array<Snippet>
62
+
63
+ /**
64
+ * Isolate driver for executing snippet code
65
+ */
66
+ driver: IsolateDriver
67
+
68
+ /**
69
+ * Original tools that become external_* bindings
70
+ * (so snippets can call external_* functions)
71
+ */
72
+ tools: Array<CodeModeTool>
73
+
74
+ /**
75
+ * Storage for updating execution stats
76
+ */
77
+ storage: SnippetStorage
78
+
79
+ /**
80
+ * Timeout for snippet execution in ms
81
+ * @default 30000
82
+ */
83
+ timeout?: number
84
+
85
+ /**
86
+ * Memory limit in bytes
87
+ * @default 128
88
+ */
89
+ memoryLimit?: number
90
+ }
91
+
92
+ /**
93
+ * Convert JSON Schema to Zod schema.
94
+ * This is a simplified converter that handles common cases.
95
+ */
96
+ function jsonSchemaToZod(schema: Record<string, unknown>): z.ZodType {
97
+ const type = schema.type as string
98
+
99
+ if (type === 'string') {
100
+ let zodString = z.string()
101
+ if (schema.description) {
102
+ zodString = zodString.describe(schema.description as string)
103
+ }
104
+ return zodString
105
+ }
106
+ if (type === 'number' || type === 'integer') {
107
+ let zodNum = z.number()
108
+ if (schema.description) {
109
+ zodNum = zodNum.describe(schema.description as string)
110
+ }
111
+ return zodNum
112
+ }
113
+ if (type === 'boolean') {
114
+ let zodBool = z.boolean()
115
+ if (schema.description) {
116
+ zodBool = zodBool.describe(schema.description as string)
117
+ }
118
+ return zodBool
119
+ }
120
+ if (type === 'array') {
121
+ const items = schema.items as Record<string, unknown> | undefined
122
+ if (items) {
123
+ return z.array(jsonSchemaToZod(items))
124
+ }
125
+ return z.array(z.unknown())
126
+ }
127
+ if (type === 'object') {
128
+ const properties = schema.properties as
129
+ | Record<string, Record<string, unknown>>
130
+ | undefined
131
+ const required = (schema.required as Array<string> | undefined) ?? []
132
+
133
+ if (properties) {
134
+ const shape: Record<string, z.ZodType> = {}
135
+ for (const [key, propSchema] of Object.entries(properties)) {
136
+ let zodProp = jsonSchemaToZod(propSchema)
137
+ if (!required.includes(key)) {
138
+ zodProp = zodProp.optional()
139
+ }
140
+ shape[key] = zodProp
141
+ }
142
+ return z.object(shape)
143
+ }
144
+ return z.record(z.string(), z.unknown())
145
+ }
146
+
147
+ // Fallback
148
+ return z.unknown()
149
+ }
150
+
151
+ /**
152
+ * Convert a single snippet to a ServerTool that the LLM can call directly.
153
+ * The snippet executes its code in the sandbox with access to external_* bindings.
154
+ */
155
+ export function snippetToTool({
156
+ snippet,
157
+ driver,
158
+ bindings,
159
+ storage,
160
+ timeout = 30000,
161
+ memoryLimit = 128,
162
+ }: SnippetToToolOptions): ServerTool<SchemaInput, SchemaInput, string> {
163
+ // Generate input and output schemas from JSON Schema
164
+ const inputSchema = jsonSchemaToZod(snippet.inputSchema)
165
+ const outputSchema = jsonSchemaToZod(snippet.outputSchema)
166
+
167
+ return toolDefinition({
168
+ name: snippet.name,
169
+ description: `[SNIPPET] ${snippet.description}`,
170
+ inputSchema,
171
+ outputSchema,
172
+ }).server(async (input: unknown, context?: ToolExecutionContext) => {
173
+ const startTime = Date.now()
174
+ const emitCustomEvent = context?.emitCustomEvent || (() => {})
175
+
176
+ // Emit snippet call event
177
+ emitCustomEvent('code_mode:snippet_call', {
178
+ snippet: snippet.name,
179
+ input,
180
+ timestamp: startTime,
181
+ })
182
+
183
+ let isolateContext = null
184
+
185
+ try {
186
+ console.log(
187
+ `[Snippet:${snippet.name}] Starting execution with input:`,
188
+ JSON.stringify(input).substring(0, 200),
189
+ )
190
+
191
+ // Wrap the snippet code to receive input as a variable
192
+ const wrappedCode = `
193
+ const input = ${JSON.stringify(input)};
194
+ ${snippet.code}
195
+ `
196
+ console.log(
197
+ `[Snippet:${snippet.name}] Wrapped code (first 500 chars):`,
198
+ wrappedCode.substring(0, 500),
199
+ )
200
+
201
+ // Strip TypeScript to JavaScript
202
+ const strippedCode = await stripTypeScript(wrappedCode)
203
+ console.log(
204
+ `[Snippet:${snippet.name}] Stripped code (first 500 chars):`,
205
+ strippedCode.substring(0, 500),
206
+ )
207
+
208
+ // Create event-aware bindings
209
+ const eventAwareBindings = createEventAwareBindings(
210
+ bindings,
211
+ emitCustomEvent,
212
+ )
213
+ console.log(
214
+ `[Snippet:${snippet.name}] Event-aware bindings:`,
215
+ Object.keys(eventAwareBindings),
216
+ )
217
+
218
+ // Create sandbox context
219
+ console.log(`[Snippet:${snippet.name}] Creating sandbox context...`)
220
+ isolateContext = await driver.createContext({
221
+ bindings: eventAwareBindings,
222
+ timeout,
223
+ memoryLimit,
224
+ })
225
+ console.log(`[Snippet:${snippet.name}] Sandbox context created`)
226
+
227
+ // Execute the code
228
+ console.log(`[Snippet:${snippet.name}] Executing code...`)
229
+ const executionResult = await isolateContext.execute(strippedCode)
230
+ console.log(`[Snippet:${snippet.name}] Execution result:`, {
231
+ success: executionResult.success,
232
+ hasValue: 'value' in executionResult,
233
+ error: executionResult.error,
234
+ logs: executionResult.logs,
235
+ })
236
+
237
+ const duration = Date.now() - startTime
238
+
239
+ if (!executionResult.success) {
240
+ console.error(
241
+ `[Snippet:${snippet.name}] Execution failed:`,
242
+ executionResult.error,
243
+ )
244
+ throw new Error(
245
+ executionResult.error?.message || 'Snippet execution failed',
246
+ )
247
+ }
248
+
249
+ // Emit success event
250
+ emitCustomEvent('code_mode:snippet_result', {
251
+ snippet: snippet.name,
252
+ result: executionResult.value,
253
+ duration,
254
+ timestamp: Date.now(),
255
+ })
256
+
257
+ // Update stats (async, don't await to not block)
258
+ storage.updateStats(snippet.name, true).catch(() => {
259
+ // Silently ignore stats update failures
260
+ })
261
+
262
+ return executionResult.value
263
+ } catch (error) {
264
+ const duration = Date.now() - startTime
265
+ console.error(`[Snippet:${snippet.name}] CAUGHT ERROR:`, {
266
+ message: error instanceof Error ? error.message : String(error),
267
+ stack: error instanceof Error ? error.stack : undefined,
268
+ duration,
269
+ })
270
+
271
+ // Emit error event
272
+ emitCustomEvent('code_mode:snippet_error', {
273
+ snippet: snippet.name,
274
+ error: error instanceof Error ? error.message : String(error),
275
+ duration,
276
+ timestamp: Date.now(),
277
+ })
278
+
279
+ // Update stats (async, don't await)
280
+ storage.updateStats(snippet.name, false).catch(() => {
281
+ // Silently ignore stats update failures
282
+ })
283
+
284
+ throw error
285
+ } finally {
286
+ if (isolateContext) {
287
+ await isolateContext.dispose()
288
+ }
289
+ }
290
+ })
291
+ }
292
+
293
+ /**
294
+ * Convert multiple snippets to ServerTools that the LLM can call directly.
295
+ * Snippets become real tools that execute their code in the sandbox.
296
+ */
297
+ export function snippetsToTools({
298
+ snippets,
299
+ driver,
300
+ tools,
301
+ storage,
302
+ timeout = 30000,
303
+ memoryLimit = 128,
304
+ }: SnippetsToToolsOptions): Array<
305
+ ServerTool<SchemaInput, SchemaInput, string>
306
+ > {
307
+ // Pre-compute bindings from tools (these are shared across all snippet executions)
308
+ console.log(
309
+ '[SnippetsToTools] Creating bindings from tools:',
310
+ tools.map((t) => t.name),
311
+ )
312
+ const bindings = toolsToBindings(tools, 'external_')
313
+ console.log('[SnippetsToTools] Created bindings:', Object.keys(bindings))
314
+
315
+ return snippets.map((snippet) =>
316
+ snippetToTool({
317
+ snippet,
318
+ driver,
319
+ bindings,
320
+ storage,
321
+ timeout,
322
+ memoryLimit,
323
+ }),
324
+ )
325
+ }
@@ -0,0 +1,275 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { existsSync } from 'node:fs'
4
+ import { createDefaultTrustStrategy } from '../trust-strategies'
5
+ import type {
6
+ Snippet,
7
+ SnippetIndexEntry,
8
+ SnippetSearchOptions,
9
+ SnippetStorage,
10
+ } from '../types'
11
+ import type { TrustStrategy } from '../trust-strategies'
12
+
13
+ export interface FileSnippetStorageOptions {
14
+ /**
15
+ * Directory path for storing snippets
16
+ */
17
+ directory: string
18
+
19
+ /**
20
+ * Trust strategy for determining snippet trust levels
21
+ * @default createDefaultTrustStrategy()
22
+ */
23
+ trustStrategy?: TrustStrategy
24
+ }
25
+
26
+ /**
27
+ * File-system based snippet storage
28
+ *
29
+ * Directory structure:
30
+ * .snippets/
31
+ * _index.json # Fast catalog loading
32
+ * fetch_github_stats/
33
+ * meta.json # Metadata (description, schemas, hints, stats)
34
+ * code.ts # The actual TypeScript code
35
+ * deploy_to_prod/
36
+ * meta.json
37
+ * code.ts
38
+ */
39
+ export function createFileSnippetStorage(
40
+ directoryOrOptions: string | FileSnippetStorageOptions,
41
+ ): SnippetStorage {
42
+ const options =
43
+ typeof directoryOrOptions === 'string'
44
+ ? { directory: directoryOrOptions }
45
+ : directoryOrOptions
46
+
47
+ const { directory, trustStrategy = createDefaultTrustStrategy() } = options
48
+ const indexPath = join(directory, '_index.json')
49
+
50
+ console.log('[FileSnippetStorage] Initialized with directory:', directory)
51
+
52
+ // Snippet names are used both as on-disk directory segments and as
53
+ // `snippet_<name>` sandbox tool names, so they must be a single safe
54
+ // identifier segment. Rejecting anything else keeps an LLM-supplied name
55
+ // (e.g. `../../etc`) from escaping `directory` during read/write/delete.
56
+ const SAFE_SNIPPET_NAME = /^[A-Za-z0-9_-]+$/
57
+
58
+ function isSafeSnippetName(name: string): boolean {
59
+ return typeof name === 'string' && SAFE_SNIPPET_NAME.test(name)
60
+ }
61
+
62
+ function assertSafeSnippetName(name: string): void {
63
+ if (!isSafeSnippetName(name)) {
64
+ throw new Error(
65
+ `Invalid snippet name ${JSON.stringify(
66
+ name,
67
+ )}: names must match /^[A-Za-z0-9_-]+$/ (no path separators or traversal).`,
68
+ )
69
+ }
70
+ }
71
+
72
+ async function ensureDirectory(): Promise<void> {
73
+ if (!existsSync(directory)) {
74
+ console.log('[FileSnippetStorage] Creating directory:', directory)
75
+ await mkdir(directory, { recursive: true })
76
+ }
77
+ }
78
+
79
+ async function loadIndex(): Promise<Array<SnippetIndexEntry>> {
80
+ await ensureDirectory()
81
+
82
+ if (!existsSync(indexPath)) {
83
+ return []
84
+ }
85
+
86
+ const content = await readFile(indexPath, 'utf-8')
87
+ return JSON.parse(content) as Array<SnippetIndexEntry>
88
+ }
89
+
90
+ async function loadAll(): Promise<Array<Snippet>> {
91
+ const index = await loadIndex()
92
+ const snippets: Array<Snippet> = []
93
+
94
+ for (const entry of index) {
95
+ const snippet = await get(entry.name)
96
+ if (snippet) {
97
+ snippets.push(snippet)
98
+ }
99
+ }
100
+
101
+ return snippets
102
+ }
103
+
104
+ async function saveIndex(index: Array<SnippetIndexEntry>): Promise<void> {
105
+ await writeFile(indexPath, JSON.stringify(index, null, 2))
106
+ }
107
+
108
+ async function get(name: string): Promise<Snippet | null> {
109
+ // Treat an unsafe name as "not found" rather than reading outside `directory`.
110
+ if (!isSafeSnippetName(name)) {
111
+ return null
112
+ }
113
+
114
+ const snippetDir = join(directory, name)
115
+ const metaPath = join(snippetDir, 'meta.json')
116
+ const codePath = join(snippetDir, 'code.ts')
117
+
118
+ if (!existsSync(metaPath)) {
119
+ return null
120
+ }
121
+
122
+ const [metaContent, code] = await Promise.all([
123
+ readFile(metaPath, 'utf-8'),
124
+ readFile(codePath, 'utf-8'),
125
+ ])
126
+
127
+ const meta = JSON.parse(metaContent) as Omit<Snippet, 'code'>
128
+ return { ...meta, code }
129
+ }
130
+
131
+ async function save(
132
+ snippet: Omit<Snippet, 'createdAt' | 'updatedAt'>,
133
+ ): Promise<Snippet> {
134
+ assertSafeSnippetName(snippet.name)
135
+ await ensureDirectory()
136
+
137
+ const snippetDir = join(directory, snippet.name)
138
+ const metaPath = join(snippetDir, 'meta.json')
139
+ const codePath = join(snippetDir, 'code.ts')
140
+
141
+ const now = new Date().toISOString()
142
+ const existing = await get(snippet.name)
143
+
144
+ const fullSnippet: Snippet = {
145
+ ...snippet,
146
+ createdAt: existing?.createdAt ?? now,
147
+ updatedAt: now,
148
+ }
149
+
150
+ // Separate code from metadata
151
+ const { code, ...meta } = fullSnippet
152
+
153
+ // Write snippet files
154
+ await mkdir(snippetDir, { recursive: true })
155
+ await Promise.all([
156
+ writeFile(metaPath, JSON.stringify(meta, null, 2)),
157
+ writeFile(codePath, code),
158
+ ])
159
+
160
+ // Update index
161
+ const index = await loadIndex()
162
+ const indexEntry: SnippetIndexEntry = {
163
+ id: fullSnippet.id,
164
+ name: fullSnippet.name,
165
+ description: fullSnippet.description,
166
+ usageHints: fullSnippet.usageHints,
167
+ trustLevel: fullSnippet.trustLevel,
168
+ }
169
+
170
+ const existingIdx = index.findIndex((s) => s.name === snippet.name)
171
+ if (existingIdx >= 0) {
172
+ index[existingIdx] = indexEntry
173
+ } else {
174
+ index.push(indexEntry)
175
+ }
176
+ await saveIndex(index)
177
+
178
+ return fullSnippet
179
+ }
180
+
181
+ async function deleteSnippet(name: string): Promise<boolean> {
182
+ assertSafeSnippetName(name)
183
+
184
+ const snippetDir = join(directory, name)
185
+
186
+ if (!existsSync(snippetDir)) {
187
+ return false
188
+ }
189
+
190
+ await rm(snippetDir, { recursive: true })
191
+
192
+ // Update index
193
+ const index = await loadIndex()
194
+ const filtered = index.filter((s) => s.name !== name)
195
+ await saveIndex(filtered)
196
+
197
+ return true
198
+ }
199
+
200
+ async function search(
201
+ query: string,
202
+ searchOptions: SnippetSearchOptions = {},
203
+ ): Promise<Array<SnippetIndexEntry>> {
204
+ const { limit = 5 } = searchOptions
205
+ const index = await loadIndex()
206
+
207
+ // Simple text matching - can be replaced with embeddings
208
+ const queryLower = query.toLowerCase()
209
+ const terms = queryLower.split(/\s+/)
210
+
211
+ const scored = index.map((snippet) => {
212
+ let score = 0
213
+ const searchText = [
214
+ snippet.name,
215
+ snippet.description,
216
+ ...snippet.usageHints,
217
+ ]
218
+ .join(' ')
219
+ .toLowerCase()
220
+
221
+ for (const term of terms) {
222
+ if (searchText.includes(term)) {
223
+ score += 1
224
+ }
225
+ // Boost exact name matches
226
+ if (snippet.name.toLowerCase().includes(term)) {
227
+ score += 2
228
+ }
229
+ }
230
+
231
+ return { snippet, score }
232
+ })
233
+
234
+ return scored
235
+ .filter((s) => s.score > 0)
236
+ .sort((a, b) => b.score - a.score)
237
+ .slice(0, limit)
238
+ .map((s) => s.snippet)
239
+ }
240
+
241
+ async function updateStats(name: string, success: boolean): Promise<void> {
242
+ const snippet = await get(name)
243
+ if (!snippet) return
244
+
245
+ const { executions, successRate } = snippet.stats
246
+ const newExecutions = executions + 1
247
+ const newSuccessRate =
248
+ (successRate * executions + (success ? 1 : 0)) / newExecutions
249
+
250
+ const newStats = { executions: newExecutions, successRate: newSuccessRate }
251
+
252
+ // Use trust strategy to calculate new trust level
253
+ const newTrustLevel = trustStrategy.calculateTrustLevel(
254
+ snippet.trustLevel,
255
+ newStats,
256
+ )
257
+
258
+ await save({
259
+ ...snippet,
260
+ stats: newStats,
261
+ trustLevel: newTrustLevel,
262
+ })
263
+ }
264
+
265
+ return {
266
+ loadIndex,
267
+ loadAll,
268
+ get,
269
+ save,
270
+ delete: deleteSnippet,
271
+ search,
272
+ updateStats,
273
+ trustStrategy,
274
+ }
275
+ }
@@ -0,0 +1,6 @@
1
+ // Storage implementations
2
+ export { createFileSnippetStorage } from './file-storage'
3
+ export { createMemorySnippetStorage } from './memory-storage'
4
+
5
+ // Re-export types
6
+ export type { SnippetStorage, Snippet, SnippetIndexEntry } from '../types'