create-loopengine 0.1.12 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,6 +43,7 @@ import {
43
43
  AgentEditNotSupportedError,
44
44
  AgentFileNotFoundError,
45
45
  type AgentEditResult,
46
+ type AgentEditableFields,
46
47
  createSessionStore,
47
48
  createCheckpointStore,
48
49
  type TurnCheckpoint,
@@ -79,6 +80,10 @@ import {
79
80
  deleteSkill,
80
81
  SkillInvalidIdError,
81
82
  SkillNotFoundError,
83
+ listSkillgardenCatalog,
84
+ readSkillgardenCatalogEntry,
85
+ addSkillgardenSkillToAgent,
86
+ SkillgardenUnavailableError,
82
87
  readActauthConfig,
83
88
  addActauthRule,
84
89
  updateActauthRule,
@@ -314,9 +319,14 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
314
319
  // configured for *this* agent, so the config page's own Skills tab
315
320
  // deliberately doesn't mix it into skills/skillsDirs below.
316
321
  const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
317
- const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
322
+ // Same defaults, same reasoning, as loopengine's own run-agent.ts
323
+ // SkillGarden/BudgetTracker construction — kept in sync there and here
324
+ // since this is what a "Skills"/"Limits & budgets" tab shows an
325
+ // operator, and it has to describe what runAgent() would actually
326
+ // resolve, not a second guess (see this function's own header comment).
327
+ const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
318
328
  const skillIndex = skillGarden.buildIndex().included
319
- const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
329
+ const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
320
330
  const systemSkillIndex = systemSkillGarden.buildIndex().included
321
331
 
322
332
  return {
@@ -326,8 +336,8 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
326
336
  ? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
327
337
  : 'custom (module exports its own createModelCall)',
328
338
  maxTurns: config.maxTurns ?? 25,
329
- contextBudgetTokens: config.contextBudgetTokens ?? 8000,
330
- skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 200,
339
+ contextBudgetTokens: config.contextBudgetTokens ?? 100000,
340
+ skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
331
341
  skillsDirs: skillsDirs,
332
342
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
333
343
  systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
@@ -644,6 +654,33 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
644
654
  return { ok: true, value: options }
645
655
  }
646
656
 
657
+ // The Overview tab's "Limits & budgets" edit form — kept separate from
658
+ // parseAgentTemplateOptions since that one's shape (AgentTemplateOptions)
659
+ // is shared with handleCreateAgent/scaffoldAgent, which has no notion of
660
+ // these three at all (a freshly scaffolded agent always starts on
661
+ // loopengine's own defaults — see AgentConfig's own doc comments). Each
662
+ // of the three is optional and independent, same as systemPrompt/model
663
+ // above.
664
+ function parsePositiveInt(value: unknown, field: string): { ok: true; value: number | undefined } | { ok: false; error: string } {
665
+ if (value === undefined || value === null) return { ok: true, value: undefined }
666
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
667
+ return { ok: false, error: `${field} must be a positive integer` }
668
+ }
669
+ return { ok: true, value }
670
+ }
671
+
672
+ function parseAgentLimitsFields(
673
+ body: Record<string, unknown>,
674
+ ): { ok: true; value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> } | { ok: false; error: string } {
675
+ const value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> = {}
676
+ for (const field of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens'] as const) {
677
+ const parsed = parsePositiveInt(body[field], field)
678
+ if (!parsed.ok) return parsed
679
+ if (parsed.value !== undefined) value[field] = parsed.value
680
+ }
681
+ return { ok: true, value }
682
+ }
683
+
647
684
  async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
648
685
  const body = await readJsonBody(req)
649
686
  if (typeof body.name !== 'string' || !body.name) {
@@ -677,9 +714,11 @@ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Pro
677
714
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
678
715
  }
679
716
 
680
- // Backs the Overview tab's System prompt / Model edit forms — either
681
- // field can be sent independently (see parseAgentTemplateOptions), the
682
- // other stays untouched both on disk and live. Persists via
717
+ // Backs the Overview tab's System prompt / Model / Limits & budgets edit
718
+ // forms — any subset of systemPrompt, model, maxTurns, contextBudgetTokens,
719
+ // skillIndexBudgetTokens can be sent independently (see
720
+ // parseAgentTemplateOptions/parseAgentLimitsFields), every other field
721
+ // stays untouched both on disk and live. Persists via
683
722
  // agent-file-admin.ts's editAgentFile (see its own doc comment for why
684
723
  // this is a surgical AST edit, not a full file regeneration), then
685
724
  // applies the exact same resolved values to *this* running process via
@@ -699,14 +738,22 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
699
738
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
700
739
  return
701
740
  }
702
- if (parsedOptions.value.systemPrompt === undefined && parsedOptions.value.model === undefined) {
703
- res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'systemPrompt or model is required' }))
741
+ const parsedLimits = parseAgentLimitsFields(body)
742
+ if (!parsedLimits.ok) {
743
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedLimits.error }))
744
+ return
745
+ }
746
+ const editFields: AgentEditableFields = { ...parsedOptions.value, ...parsedLimits.value }
747
+ if (Object.keys(editFields).length === 0) {
748
+ res
749
+ .writeHead(400, { 'content-type': 'application/json' })
750
+ .end(JSON.stringify({ error: 'systemPrompt, model, maxTurns, contextBudgetTokens, or skillIndexBudgetTokens is required' }))
704
751
  return
705
752
  }
706
753
 
707
754
  let result: AgentEditResult
708
755
  try {
709
- result = editAgentFile(agentName, parsedOptions.value)
756
+ result = editAgentFile(agentName, editFields)
710
757
  } catch (err) {
711
758
  const status =
712
759
  err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
@@ -716,6 +763,9 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
716
763
 
717
764
  const configPatch: Partial<AgentConfig> = {}
718
765
  if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
766
+ if (result.maxTurns !== undefined) configPatch.maxTurns = result.maxTurns
767
+ if (result.contextBudgetTokens !== undefined) configPatch.contextBudgetTokens = result.contextBudgetTokens
768
+ if (result.skillIndexBudgetTokens !== undefined) configPatch.skillIndexBudgetTokens = result.skillIndexBudgetTokens
719
769
  let createModelCall: RegistryEntry['createModelCall'] | undefined
720
770
  if (result.model) {
721
771
  configPatch.model = result.model
@@ -782,6 +832,69 @@ function handleSkillDelete(res: ServerResponse, agentName: string, skillId: stri
782
832
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
783
833
  }
784
834
 
835
+ // Backs the Skills tab's "Browse skillgarden catalog" section — a
836
+ // read-only, agent-independent list of everything skillgarden ships in
837
+ // its own bundled registry (see loopengine's own web/skillgarden-admin.ts).
838
+ // Not wrapped in a per-agent :name segment since the catalog itself
839
+ // isn't scoped to any one agent; only the POST below (actually
840
+ // installing an entry) is.
841
+ function handleSkillgardenCatalogGet(res: ServerResponse): void {
842
+ try {
843
+ // Computed before writeHead, not inlined into .end()'s own argument —
844
+ // res.writeHead(200, ...) commits the status line the instant it's
845
+ // called, so a throw evaluated *after* it (as it would be if it were
846
+ // inlined as .end()'s argument) leaves headers already sent as 200
847
+ // with no body, and the catch block's own writeHead(status, ...)
848
+ // then fails outright (ERR_HTTP_HEADERS_SENT) instead of ever
849
+ // reaching the client.
850
+ const catalog = listSkillgardenCatalog()
851
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(catalog))
852
+ } catch (err) {
853
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 500
854
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
855
+ }
856
+ }
857
+
858
+ // Full SKILL.md body for the catalog's own "View" toggle — kept as a
859
+ // separate route from the list above (rather than inlining every body
860
+ // into it) for the same reason the Skills tab's own edit form fetches a
861
+ // skill's body lazily instead of shipping it in the initial list.
862
+ function handleSkillgardenCatalogEntryGet(res: ServerResponse, category: string, id: string): void {
863
+ try {
864
+ // See handleSkillgardenCatalogGet's own comment for why this is
865
+ // computed before writeHead rather than inlined into .end()'s
866
+ // argument.
867
+ const entry = readSkillgardenCatalogEntry(category, id)
868
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(entry))
869
+ } catch (err) {
870
+ const status = err instanceof SkillgardenUnavailableError ? 503 : 404
871
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
872
+ }
873
+ }
874
+
875
+ // Installs a catalog entry into agents/:name/skills/ — delegates to
876
+ // skillgarden's own addSkill (see skillgarden-admin.ts), which throws
877
+ // plain Errors (no typed classes) for "unknown skill"/"already exists",
878
+ // so those two are told apart by message shape rather than instanceof,
879
+ // same as any other dependency that doesn't export typed errors for its
880
+ // own failure modes.
881
+ async function handleSkillgardenCatalogAdd(req: IncomingMessage, res: ServerResponse, agentName: string, category: string, id: string): Promise<void> {
882
+ if (!getEntry(agentName)) {
883
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
884
+ return
885
+ }
886
+ const body = await readJsonBody(req)
887
+ const force = body.force === true
888
+ try {
889
+ const result = addSkillgardenSkillToAgent(agentName, category, id, force)
890
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, id: result.id }))
891
+ } catch (err) {
892
+ const message = err instanceof Error ? err.message : String(err)
893
+ const status = err instanceof SkillgardenUnavailableError ? 503 : /already exists/.test(message) ? 409 : /^Unknown skill/.test(message) ? 404 : 500
894
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message }))
895
+ }
896
+ }
897
+
785
898
  // Backs the Actauth tab's rule editor — parses a rule body shared by both
786
899
  // the add (POST) and update (PUT, minus `name`) routes below.
787
900
  function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
@@ -1906,6 +2019,23 @@ const server = createServer(async (req, res) => {
1906
2019
  return
1907
2020
  }
1908
2021
 
2022
+ // Backs the Skills tab's "Browse skillgarden catalog" section — see
2023
+ // the three handlers above it.
2024
+ if (req.method === 'GET' && pathname === '/skillgarden-catalog') {
2025
+ handleSkillgardenCatalogGet(res)
2026
+ return
2027
+ }
2028
+ const skillgardenEntryMatch = req.method === 'GET' && pathname.match(/^\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2029
+ if (skillgardenEntryMatch) {
2030
+ handleSkillgardenCatalogEntryGet(res, decodeURIComponent(skillgardenEntryMatch[1]), decodeURIComponent(skillgardenEntryMatch[2]))
2031
+ return
2032
+ }
2033
+ const skillgardenAddMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
2034
+ if (skillgardenAddMatch) {
2035
+ await handleSkillgardenCatalogAdd(req, res, decodeURIComponent(skillgardenAddMatch[1]), decodeURIComponent(skillgardenAddMatch[2]), decodeURIComponent(skillgardenAddMatch[3]))
2036
+ return
2037
+ }
2038
+
1909
2039
  // Backs the Actauth tab's rule editor and default_decision control.
1910
2040
  // The default-decision route is checked before the :ruleName routes
1911
2041
  // right below since 'default-decision' would otherwise itself match
@@ -9,9 +9,9 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.4",
12
+ "loopengine": "^0.1.6",
13
13
  "actauth": "^0.0.12",
14
- "skillgarden": "^0.0.5"
14
+ "skillgarden": "^0.0.9"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^22.10.0",