Skip to content

Commit d317a3f

Browse files
authored
feat(skill): switch TMA knowledge skill to Node (#29)
1 parent d79b7e7 commit d317a3f

7 files changed

Lines changed: 349 additions & 187 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ The package also ships a built-in TMA overlay and applies it after cloning
6161
- `AGENTS.md` is shipped by the template for repo-level AI agent instructions.
6262
- `CLAUDE.md` is shipped by the template for Claude Code project memory.
6363
- `.agents/skills/tma-knowledge-search` is shipped by the template as the local TMA knowledge-search skill for compatible agents.
64+
- The bundled TMA knowledge-search skill is Node-based and runs via `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs "<query>"`.
6465
- if `codex` is installed locally, bootstrap also registers the same MCP server in
6566
the global Codex MCP config automatically.
6667
- bootstrap also mirrors `tma-knowledge-search` into `~/.codex/skills` so Codex can discover the same skill natively.

packages/app/template-nextjs-overlay/.agents/skills/tma-knowledge-search/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ Use this skill when local repo context is not enough for a Telegram Mini App que
1010
## Workflow
1111

1212
1. Form a focused English query about the TMA implementation detail you need.
13-
2. Run `scripts/search_tma_knowledge.py "<query>"`.
14-
3. Read the returned `answer` first, then inspect any `sources`.
15-
4. Use the API result as the primary TMA-specific reference in your answer or implementation plan.
13+
2. From the project root, run `node .agents/skills/tma-knowledge-search/scripts/search_tma_knowledge.mjs "<query>"`.
14+
3. If you are already inside the skill directory, run `node scripts/search_tma_knowledge.mjs "<query>"`.
15+
4. Read the returned `answer` first, then inspect any `sources`.
16+
5. Use the API result as the primary TMA-specific reference in your answer or implementation plan.
1617

1718
## Query Rules
1819

@@ -30,6 +31,6 @@ Use this skill when local repo context is not enough for a Telegram Mini App que
3031

3132
## Resources
3233

33-
- `scripts/search_tma_knowledge.py`: sends the POST request and prints a readable summary or raw JSON.
34+
- `scripts/search_tma_knowledge.mjs`: sends the POST request and prints a readable summary or raw JSON.
3435
- The script automatically uses `SPAWNDOCK_API_TOKEN`, `API_TOKEN`, or the nearest `spawndock.config.json` `apiToken` when available.
3536
- `references/api.md`: request and response contract for the knowledge endpoint.
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
#!/usr/bin/env node
2+
3+
import { existsSync, readFileSync } from "node:fs"
4+
import { dirname, join, resolve } from "node:path"
5+
import process from "node:process"
6+
7+
const API_URL = "https://spawn-dock.w3voice.net/knowledge/api/v1/search"
8+
9+
const printUsage = () => {
10+
console.log(`Usage: node scripts/search_tma_knowledge.mjs "<query>" [options]
11+
12+
Options:
13+
--locale <locale> Response locale (default: en)
14+
--api-token <token> Optional Bearer token override
15+
--config <path> Optional path to spawndock.config.json
16+
--timeout <seconds> HTTP timeout in seconds (default: 20)
17+
--retries <count> Retry count for transient HTTP 5xx failures (default: 2)
18+
--raw Print raw JSON response
19+
-h, --help Show this help text`)
20+
}
21+
22+
const readOptionValue = (argv, index, optionName) => {
23+
const value = argv[index]
24+
if (typeof value !== "string" || value.length === 0) {
25+
throw new Error(`Missing value for ${optionName}`)
26+
}
27+
28+
return value
29+
}
30+
31+
const parseTimeout = (rawValue) => {
32+
const timeout = Number.parseFloat(rawValue)
33+
if (!Number.isFinite(timeout) || timeout <= 0) {
34+
throw new Error("Timeout must be a positive number.")
35+
}
36+
37+
return timeout
38+
}
39+
40+
const parseRetries = (rawValue) => {
41+
const retries = Number.parseInt(rawValue, 10)
42+
if (!Number.isInteger(retries) || retries < 0) {
43+
throw new Error("Retries must be a non-negative integer.")
44+
}
45+
46+
return retries
47+
}
48+
49+
const parseArgs = (argv) => {
50+
const options = {
51+
help: false,
52+
locale: "en",
53+
apiToken: "",
54+
config: "",
55+
timeoutSeconds: 20,
56+
raw: false,
57+
retries: 2,
58+
query: "",
59+
}
60+
const positionals = []
61+
62+
for (let index = 0; index < argv.length; index += 1) {
63+
const arg = argv[index]
64+
65+
switch (arg) {
66+
case "-h":
67+
case "--help":
68+
options.help = true
69+
break
70+
case "--locale":
71+
index += 1
72+
options.locale = readOptionValue(argv, index, arg)
73+
break
74+
case "--api-token":
75+
index += 1
76+
options.apiToken = readOptionValue(argv, index, arg)
77+
break
78+
case "--config":
79+
index += 1
80+
options.config = readOptionValue(argv, index, arg)
81+
break
82+
case "--timeout":
83+
index += 1
84+
options.timeoutSeconds = parseTimeout(readOptionValue(argv, index, arg))
85+
break
86+
case "--retries":
87+
index += 1
88+
options.retries = parseRetries(readOptionValue(argv, index, arg))
89+
break
90+
case "--raw":
91+
options.raw = true
92+
break
93+
default:
94+
if (arg.startsWith("--")) {
95+
throw new Error(`Unknown option: ${arg}`)
96+
}
97+
positionals.push(arg)
98+
break
99+
}
100+
}
101+
102+
if (!options.help) {
103+
if (positionals.length === 0) {
104+
throw new Error("Missing query.")
105+
}
106+
107+
options.query = positionals.join(" ")
108+
}
109+
110+
return options
111+
}
112+
113+
const expandHomePath = (inputPath) => {
114+
if (!inputPath.startsWith("~")) {
115+
return inputPath
116+
}
117+
118+
const home = process.env["HOME"]
119+
if (!home || home.length === 0) {
120+
return inputPath
121+
}
122+
123+
if (inputPath === "~") {
124+
return home
125+
}
126+
127+
if (inputPath.startsWith("~/")) {
128+
return join(home, inputPath.slice(2))
129+
}
130+
131+
return inputPath
132+
}
133+
134+
const findConfigPath = (explicitPath) => {
135+
if (explicitPath.length > 0) {
136+
const resolvedPath = resolve(expandHomePath(explicitPath))
137+
return existsSync(resolvedPath) ? resolvedPath : null
138+
}
139+
140+
let currentDir = process.cwd()
141+
142+
while (true) {
143+
const candidate = join(currentDir, "spawndock.config.json")
144+
if (existsSync(candidate)) {
145+
return candidate
146+
}
147+
148+
const parentDir = dirname(currentDir)
149+
if (parentDir === currentDir) {
150+
break
151+
}
152+
currentDir = parentDir
153+
}
154+
155+
return null
156+
}
157+
158+
const readConfigApiToken = (configPath) => {
159+
if (!configPath) {
160+
return null
161+
}
162+
163+
try {
164+
const data = JSON.parse(readFileSync(configPath, "utf8"))
165+
const token = data?.apiToken
166+
return typeof token === "string" && token.trim().length > 0 ? token.trim() : null
167+
} catch {
168+
return null
169+
}
170+
}
171+
172+
const resolveApiToken = (cliToken, configPath) => {
173+
if (cliToken.trim().length > 0) {
174+
return cliToken.trim()
175+
}
176+
177+
for (const key of ["SPAWNDOCK_API_TOKEN", "API_TOKEN"]) {
178+
const value = process.env[key]
179+
if (typeof value === "string" && value.trim().length > 0) {
180+
return value.trim()
181+
}
182+
}
183+
184+
return readConfigApiToken(configPath)
185+
}
186+
187+
const sleep = (ms) => new Promise((resolveSleep) => {
188+
setTimeout(resolveSleep, ms)
189+
})
190+
191+
const parseJsonResponse = (text) => {
192+
try {
193+
return JSON.parse(text)
194+
} catch {
195+
throw new Error(`Invalid JSON response from knowledge API:\n${text}`)
196+
}
197+
}
198+
199+
const requestKnowledge = async (query, locale, timeoutSeconds, retries, apiToken) => {
200+
const payload = JSON.stringify({ query, locale })
201+
202+
for (let attempt = 0; attempt <= retries; attempt += 1) {
203+
const headers = {
204+
accept: "application/json",
205+
"content-type": "application/json",
206+
}
207+
208+
if (apiToken) {
209+
headers.authorization = `Bearer ${apiToken}`
210+
}
211+
212+
const controller = new AbortController()
213+
const timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000)
214+
215+
try {
216+
const response = await fetch(API_URL, {
217+
method: "POST",
218+
headers,
219+
body: payload,
220+
signal: controller.signal,
221+
})
222+
const responseText = await response.text()
223+
224+
if (!response.ok) {
225+
if (response.status >= 500 && attempt < retries) {
226+
await sleep(Math.min(2 ** attempt, 5) * 1000)
227+
continue
228+
}
229+
230+
throw new Error(`HTTP error: ${response.status}\n${responseText}`)
231+
}
232+
233+
return parseJsonResponse(responseText)
234+
} catch (error) {
235+
if (error instanceof Error && error.name === "AbortError") {
236+
throw new Error(`Request timed out after ${timeoutSeconds} seconds.`)
237+
}
238+
239+
throw error
240+
} finally {
241+
clearTimeout(timeoutId)
242+
}
243+
}
244+
245+
throw new Error("Unreachable retry loop")
246+
}
247+
248+
const formatResponse = (data) => {
249+
const lines = []
250+
const answer = data?.answer
251+
const sources = Array.isArray(data?.sources) ? data.sources : []
252+
const meta = data?.meta && typeof data.meta === "object" ? data.meta : null
253+
254+
lines.push("Answer:")
255+
lines.push(typeof answer === "string" && answer.length > 0 ? answer : "(empty)")
256+
257+
if (sources.length > 0) {
258+
lines.push("")
259+
lines.push("Sources:")
260+
261+
sources.forEach((source, index) => {
262+
if (source && typeof source === "object") {
263+
const title = source.title || source.name || `Source ${index + 1}`
264+
const url = source.url || source.href || ""
265+
const snippet = source.snippet || source.text || ""
266+
let line = `${index + 1}. ${title}`
267+
268+
if (url) {
269+
line += ` - ${url}`
270+
}
271+
272+
lines.push(line)
273+
274+
if (snippet) {
275+
lines.push(` ${snippet}`)
276+
}
277+
278+
return
279+
}
280+
281+
lines.push(`${index + 1}. ${String(source)}`)
282+
})
283+
}
284+
285+
if (meta) {
286+
lines.push("")
287+
lines.push("Meta:")
288+
lines.push(JSON.stringify(meta, null, 2))
289+
}
290+
291+
return lines.join("\n")
292+
}
293+
294+
const main = async () => {
295+
let options
296+
297+
try {
298+
options = parseArgs(process.argv.slice(2))
299+
} catch (error) {
300+
console.error(error instanceof Error ? error.message : String(error))
301+
console.error("")
302+
printUsage()
303+
return 1
304+
}
305+
306+
if (options.help) {
307+
printUsage()
308+
return 0
309+
}
310+
311+
const configPath = findConfigPath(options.config)
312+
const apiToken = resolveApiToken(options.apiToken, configPath)
313+
314+
try {
315+
const data = await requestKnowledge(
316+
options.query,
317+
options.locale,
318+
options.timeoutSeconds,
319+
options.retries,
320+
apiToken,
321+
)
322+
323+
if (options.raw) {
324+
console.log(JSON.stringify(data, null, 2))
325+
} else {
326+
console.log(formatResponse(data))
327+
}
328+
329+
return 0
330+
} catch (error) {
331+
console.error(error instanceof Error ? error.message : String(error))
332+
return 1
333+
}
334+
}
335+
336+
process.exitCode = await main()

0 commit comments

Comments
 (0)