loopengine 0.1.24 → 0.1.28

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.
@@ -18,8 +18,8 @@
18
18
  // "Upgrading" section).
19
19
  import { createHash } from 'node:crypto';
20
20
  import { execFileSync } from 'node:child_process';
21
- import { basename, join } from 'node:path';
22
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { basename, dirname, join } from 'node:path';
22
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { parse as parseYaml } from 'yaml';
25
25
  import semver from 'semver';
@@ -47,6 +47,76 @@ export class AbilityAlreadyInstalledError extends Error {
47
47
  // the agent's own root instead of its skills/ folder.
48
48
  const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
49
49
  const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
50
+ /** Two abilities can each declare a skill with the same bare id (both
51
+ * ship a "web-search" skill, say) — refusing the second install outright
52
+ * would make them mutually exclusive for no real reason, since skills
53
+ * don't have to be globally unique the way a model-callable tool name
54
+ * does. Namespacing under the ability's own name only when the bare id
55
+ * is already taken — never unconditionally — keeps the common,
56
+ * no-conflict case's directory layout exactly as it's always been.
57
+ * Reuses SkillGarden's own nested-directory namespacing (discovery.ts:
58
+ * `deploy/web/SKILL.md` -> addressable as `deploy:web`) for free, so a
59
+ * namespaced skill just becomes addressable as `<abilityName>:<skillId>`
60
+ * with no changes needed on the loading side at all. */
61
+ function namespacedSkillId(abilityName, skillId) {
62
+ // abilityName came off the ability's own package.json "name" — for a
63
+ // scoped npm name ("@company/pkg") this is two path segments, which is
64
+ // fine (SkillGarden namespaces however deep the nesting goes), but each
65
+ // segment still has to be a safe path component on its own: same
66
+ // '..'-as-a-path-segment guard TOOL_NAME_PATTERN/SKILL_ID_PATTERN's own
67
+ // comment above already explains the need for, just for a manifest's
68
+ // "name" field instead of a tool/skill declaration.
69
+ for (const segment of abilityName.split('/')) {
70
+ if (segment === '' || segment === '.' || segment === '..') {
71
+ throw new AbilityManifestError(`Ability name '${abilityName}' isn't safe to use as a skill namespace.`);
72
+ }
73
+ }
74
+ return `${abilityName}/${skillId}`;
75
+ }
76
+ // A tool name (unlike a skill id) is a flat identifier, not a path — it
77
+ // has to satisfy TOOL_NAME_PATTERN on its own (it becomes both a file
78
+ // basename and a JS identifier via toCamelCase), so an ability's raw
79
+ // package.json "name" can't be used directly the way it can as a path
80
+ // segment for a skill (hyphens, "@scope/", dots aren't valid there).
81
+ // Collapsing every run of non-alphanumeric characters to a single "_"
82
+ // and guaranteeing a leading letter (TOOL_NAME_PATTERN requires one)
83
+ // always produces a safe result — never throws, unlike
84
+ // namespacedSkillId, since there's no filesystem path to escape here.
85
+ function sanitizeForToolName(name) {
86
+ const cleaned = name
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, '_')
89
+ .replace(/^_+|_+$/g, '');
90
+ return cleaned.length > 0 && /^[a-z]/.test(cleaned) ? cleaned : `a_${cleaned}`;
91
+ }
92
+ /** Two abilities can each declare a tool with the same bare name too —
93
+ * but unlike a skill id, a tool's name is the literal, model-callable
94
+ * function name: run-agent.ts's own dedupeToolsByName keeps only the
95
+ * *last* same-named entry and silently drops the rest before the model
96
+ * ever sees them (its own doc comment: "a name collision would still
97
+ * show up twice in toolSchemas, confusing (or rejected outright by) a
98
+ * real model API"), so two same-named tools can never actually coexist
99
+ * as distinct, callable tools the way two same-named skills can — only
100
+ * namespacing the *name itself* avoids that silent drop. */
101
+ function namespacedToolName(abilityName, toolName) {
102
+ return `${sanitizeForToolName(abilityName)}__${toolName}`;
103
+ }
104
+ /** Whether `manifestToolFile` (a bare path straight off a manifest, e.g.
105
+ * "tools/web_search.ts") is the file an installed `installedToolName`
106
+ * (bare, or namespaced under `abilityName` on a collision — see
107
+ * namespacedToolName) actually came from. Used by upgradeAbility to
108
+ * match a provenance-recorded, possibly-namespaced name back to the
109
+ * right manifest entry — comparing the *installed* name against a
110
+ * freshly-recomputed candidate, rather than trying to strip a namespace
111
+ * prefix back off `installedToolName` textually, since "<ability>__"
112
+ * isn't a structural separator the way a skill id's "/" is: a bare tool
113
+ * name could legitimately contain "__" on its own, so string-stripping
114
+ * it back off would be ambiguous in a way recomputing forward and
115
+ * comparing never is. */
116
+ function matchesInstalledToolName(abilityName, manifestToolFile, installedToolName) {
117
+ const bareToolName = basename(manifestToolFile, '.ts');
118
+ return bareToolName === installedToolName || namespacedToolName(abilityName, bareToolName) === installedToolName;
119
+ }
50
120
  function provenancePath(agentName) {
51
121
  return join(agentDir(agentName), '.loopengine-abilities.json');
52
122
  }
@@ -227,25 +297,36 @@ export async function installAbility(agentName, spec, options = {}) {
227
297
  // tool, just applied ability-wide.
228
298
  const toolNames = [];
229
299
  for (const toolFile of toolFiles) {
230
- const toolName = basename(toolFile, '.ts');
231
- if (!TOOL_NAME_PATTERN.test(toolName)) {
300
+ const bareToolName = basename(toolFile, '.ts');
301
+ if (!TOOL_NAME_PATTERN.test(bareToolName)) {
232
302
  throw new AbilityManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
233
303
  }
234
- toolNames.push(toolName);
235
- if (existsSync(join(toolsDir, `${toolName}.ts`))) {
236
- throw new AbilityCollisionError(`agents/${agentName}/tools/${toolName}.ts already exists.`);
304
+ // The bare name if it's free; otherwise namespace under this
305
+ // ability's own name rather than refusing the install outright —
306
+ // see namespacedToolName's own doc comment for why this still
307
+ // leaves both tools genuinely callable, unlike a same-named actauth
308
+ // rule (below), which has no such option.
309
+ const installedToolName = existsSync(join(toolsDir, `${bareToolName}.ts`)) ? namespacedToolName(manifest.name, bareToolName) : bareToolName;
310
+ if (existsSync(join(toolsDir, `${installedToolName}.ts`))) {
311
+ throw new AbilityCollisionError(`agents/${agentName}/tools/${installedToolName}.ts already exists.`);
237
312
  }
313
+ toolNames.push(installedToolName);
238
314
  }
239
315
  const skillIds = [];
240
316
  for (const skillDir of skillDirs) {
241
- const skillId = basename(skillDir);
242
- if (!SKILL_ID_PATTERN.test(skillId)) {
317
+ const bareSkillId = basename(skillDir);
318
+ if (!SKILL_ID_PATTERN.test(bareSkillId)) {
243
319
  throw new AbilityManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
244
320
  }
245
- skillIds.push(skillId);
246
- if (existsSync(join(skillsDirPath, skillId))) {
247
- throw new AbilityCollisionError(`agents/${agentName}/skills/${skillId}/ already exists.`);
321
+ // The bare id if it's free; otherwise namespace under this ability's
322
+ // own name rather than refusing the install outright — see
323
+ // namespacedSkillId's own doc comment for why this is safe to do
324
+ // (skills, unlike tools, don't need a single global namespace).
325
+ const installedSkillId = existsSync(join(skillsDirPath, bareSkillId)) ? namespacedSkillId(manifest.name, bareSkillId) : bareSkillId;
326
+ if (existsSync(join(skillsDirPath, installedSkillId))) {
327
+ throw new AbilityCollisionError(`agents/${agentName}/skills/${installedSkillId}/ already exists.`);
248
328
  }
329
+ skillIds.push(installedSkillId);
249
330
  }
250
331
  const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
251
332
  for (const rule of rules) {
@@ -261,27 +342,51 @@ export async function installAbility(agentName, spec, options = {}) {
261
342
  if (toolNames.length > 0) {
262
343
  mkdirSync(toolsDir, { recursive: true });
263
344
  const indexPath = join(toolsDir, 'index.ts');
264
- for (const toolFile of toolFiles) {
265
- const toolName = basename(toolFile, '.ts');
345
+ for (const [i, toolFile] of toolFiles.entries()) {
346
+ const bareToolName = basename(toolFile, '.ts');
347
+ const installedToolName = toolNames[i];
266
348
  const code = readFileSync(join(abilityDir, toolFile), 'utf8');
267
- const destPath = join(toolsDir, `${toolName}.ts`);
349
+ const destPath = join(toolsDir, `${installedToolName}.ts`);
268
350
  writeFileSync(destPath, code);
269
- contentHashes[`tools/${toolName}.ts`] = sha256(code);
270
- const exportName = toCamelCase(toolName);
351
+ contentHashes[`tools/${installedToolName}.ts`] = sha256(code);
352
+ // Plain case: the file's own export is imported and used as-is,
353
+ // exactly as before. Namespaced case: the file's own internal
354
+ // export name is untouched (still whatever the ability itself
355
+ // authored — importing it under that same bare name from two
356
+ // different abilities' files would be a duplicate top-level
357
+ // identifier), so the import gets aliased, and a small wrapper
358
+ // `const` overrides the actual model-facing `name` field, which
359
+ // the JS identifier alone has no effect on. See addToolToIndex's
360
+ // own doc comment for why `importSpecifier`/`arrayExpression` are
361
+ // separate parameters.
362
+ const sourceExportName = toCamelCase(bareToolName);
363
+ const namespaced = installedToolName !== bareToolName;
364
+ const arrayExpression = namespaced ? toCamelCase(installedToolName) : sourceExportName;
365
+ const importSpecifier = namespaced ? `${sourceExportName} as ${arrayExpression}Source` : sourceExportName;
366
+ const preamble = namespaced ? `const ${arrayExpression}: ToolDefinition = { ...${arrayExpression}Source, name: '${installedToolName}' }` : undefined;
271
367
  if (existsSync(indexPath)) {
272
- addToolToIndex(indexPath, toolName, exportName);
368
+ addToolToIndex(indexPath, installedToolName, importSpecifier, arrayExpression, preamble);
273
369
  }
274
370
  else {
275
- writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${exportName} } from './${toolName}.js'\n\nexport const tools: ToolDefinition[] = [${exportName}]\n`);
371
+ const preambleBlock = preamble ? `${preamble}\n\n` : '';
372
+ writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${importSpecifier} } from './${installedToolName}.js'\n\n${preambleBlock}export const tools: ToolDefinition[] = [${arrayExpression}]\n`);
276
373
  }
277
374
  }
278
375
  }
279
- // Copy skill directories verbatim.
280
- for (const skillDir of skillDirs) {
281
- const skillId = basename(skillDir);
376
+ // Copy skill directories verbatim — skillIds[i] is skillDirs[i]'s
377
+ // already-resolved installed id (bare, or namespaced under this
378
+ // ability's own name if the bare id collided — computed once, above,
379
+ // not recomputed here, so this loop can't disagree with the collision
380
+ // check that already ran against it).
381
+ for (const [i, skillDir] of skillDirs.entries()) {
382
+ const skillId = skillIds[i];
282
383
  const srcPath = join(abilityDir, skillDir);
283
384
  const destPath = join(skillsDirPath, skillId);
284
- mkdirSync(skillsDirPath, { recursive: true });
385
+ // dirname(destPath), not skillsDirPath — a namespaced skillId
386
+ // ("<abilityName>/<skillId>") needs its own intermediate directory
387
+ // created first; cpSync creates destPath itself but not necessarily
388
+ // parents beyond that.
389
+ mkdirSync(dirname(destPath), { recursive: true });
285
390
  cpSync(srcPath, destPath, { recursive: true });
286
391
  const skillMdPath = join(destPath, 'SKILL.md');
287
392
  if (existsSync(skillMdPath)) {
@@ -397,8 +502,12 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
397
502
  const newContentHashes = { ...record.contentHashes };
398
503
  for (const toolName of record.tools) {
399
504
  const relPath = `tools/${toolName}.ts`;
400
- const oldFile = (oldManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
401
- const newFile = (newManifest.tools ?? []).find((f) => basename(f, '.ts') === toolName);
505
+ // matchesInstalledToolName, not a bare basename(f, '.ts') === toolName
506
+ // comparison — toolName may be namespaced (see namespacedToolName),
507
+ // and "<ability>__" isn't a structural separator safe to strip back
508
+ // off textually the way a skill id's "/" is.
509
+ const oldFile = (oldManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
510
+ const newFile = (newManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
402
511
  if (!oldFile || !newFile) {
403
512
  results.push({ path: relPath, status: 'unchanged' });
404
513
  continue;
@@ -418,8 +527,13 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
418
527
  }
419
528
  for (const skillId of record.skills) {
420
529
  const relPath = `skills/${skillId}/SKILL.md`;
421
- const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === skillId);
422
- const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === skillId);
530
+ // basename(skillId), not skillId itself — a namespaced install
531
+ // ("<abilityName>/<skillId>") still has to match against the
532
+ // manifest's own bare skill dir names, which never carry the
533
+ // namespace (that's only ever added at install time, on collision).
534
+ const bareSkillId = basename(skillId);
535
+ const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
536
+ const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
423
537
  const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
424
538
  const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
425
539
  if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
@@ -525,6 +639,20 @@ export function removeAbility(agentName, abilityName, force = false) {
525
639
  }
526
640
  rmSync(join(agentDir(agentName), 'skills', skillId), { recursive: true, force: true });
527
641
  removed.push(`skills/${skillId}/`);
642
+ // A namespaced skillId ("<ability-name>/<id>", see namespacedSkillId)
643
+ // leaves its own now-empty "<ability-name>/" namespace directory
644
+ // behind — best-effort cleanup, not "refuse rather than guess": rmdir
645
+ // itself already refuses (ENOTEMPTY) if another skill from this same
646
+ // ability is still namespaced under it, which is exactly the case
647
+ // this should leave alone.
648
+ if (skillId.includes('/')) {
649
+ try {
650
+ rmdirSync(join(agentDir(agentName), 'skills', dirname(skillId)));
651
+ }
652
+ catch {
653
+ // Not empty, or already gone — either way, nothing to do.
654
+ }
655
+ }
528
656
  }
529
657
  for (const ruleName of record.actauthRules) {
530
658
  try {
@@ -1123,10 +1123,19 @@ export const agentsConfigPageHtml = `<!doctype html>
1123
1123
  }
1124
1124
 
1125
1125
  function renderEnvRow(v) {
1126
+ // More than one ability declaring the same name doesn't get resolved
1127
+ // here — there's one .env per project, so both read whatever single
1128
+ // value ends up set, whether that's actually correct or a coincidence
1129
+ // (see env-admin.ts's own doc comment). Listing every ability instead
1130
+ // of just the first one seen at least makes that visible to whoever's
1131
+ // looking, instead of silently hiding all but one.
1132
+ var sharedNote = v.abilityNames.length > 1
1133
+ ? ' <span class="hint" title="More than one installed ability declares this name — there is only one value for it project-wide, so check whether they actually need the same one.">(shared)</span>'
1134
+ : '';
1126
1135
  return '<tr>' +
1127
1136
  '<td><code>' + escapeHtml(v.name) + '</code></td>' +
1128
1137
  '<td>' + escapeHtml(v.description || '') + '</td>' +
1129
- '<td class="hint">' + escapeHtml(v.abilityName) + '</td>' +
1138
+ '<td class="hint">' + escapeHtml(v.abilityNames.join(', ')) + sharedNote + '</td>' +
1130
1139
  '<td>' + (v.set ? '<span class="hint">set</span>' : '<span class="error">not set</span>') + '</td>' +
1131
1140
  '<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
1132
1141
  '<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>' +
@@ -1138,7 +1147,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1138
1147
  function renderEnvConfigHtml(vars) {
1139
1148
  if (!vars.length) return '<p class="hint">No installed ability has declared any environment variables for this agent yet.</p>';
1140
1149
  var rows = vars.map(renderEnvRow).join('');
1141
- return '<table><thead><tr><th>Name</th><th>Description</th><th>Ability</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
1150
+ return '<table><thead><tr><th>Name</th><th>Description</th><th>Required by</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
1142
1151
  }
1143
1152
 
1144
1153
  function envContentEl() {
@@ -4,17 +4,36 @@ export interface DeclaredEnvVar {
4
4
  name: string;
5
5
  description?: string;
6
6
  secret: boolean;
7
- abilityName: string;
7
+ /** Every ability that declares this name, in install order — usually
8
+ * one, but see listDeclaredEnvVars's own doc comment for why this is
9
+ * an array, not a single name. */
10
+ abilityNames: string[];
8
11
  set: boolean;
9
12
  }
10
13
  /** Every env var any ability installed for `agentName` declared it
11
- * needs, deduplicated by name — ABILITIES.md's own open question on
12
- * cross-ability name collisions applies here too (one .env per
13
- * *project*, not per agent, so two unrelated abilities declaring the
14
- * same name can't actually be told apart; the first one seen wins the
15
- * description shown). `set` is read live off `process.env`, not cached,
16
- * so it reflects whatever the last `setEnvVar` call — or a plain
17
- * restart picking up `.env` — actually did. */
14
+ * needs, merged by name. There's one .env per *project*, not per agent
15
+ * or per ability, so this can't actually resolve a genuine cross-ability
16
+ * name collision (two abilities declaring, say, "API_KEY" for two
17
+ * unrelated services still both read whichever single value ends up
18
+ * set — there is no per-ability slot to give them) — unlike a tool or
19
+ * skill name collision (see ability-manager.ts's namespacedToolName/
20
+ * namespacedSkillId), an env var name is a literal `process.env.X`
21
+ * reference baked into the ability's own code, not a label loopengine's
22
+ * own generated glue controls, so there's nothing here to safely rename.
23
+ * What this *can* do, and used to not: surface every ability that
24
+ * declares a given name (`abilityNames`, plural) instead of silently
25
+ * keeping only the first one seen and hiding the rest — turns a
26
+ * collision from invisible into something a human looking at the
27
+ * Environment tab can actually notice and judge (a real conflict
28
+ * needing one ability's own env var renamed, or a coincidence that's
29
+ * actually fine because both intentionally share one real credential).
30
+ * `secret` is true if *any* declaring ability marked it secret — the
31
+ * safer default, since treating a real secret as non-secret because a
32
+ * different ability's own declaration happened to be checked first
33
+ * would be the one direction genuinely worth avoiding. `set` is read
34
+ * live off `process.env`, not cached, so it reflects whatever the last
35
+ * `setEnvVar` call — or a plain restart picking up `.env` — actually
36
+ * did. */
18
37
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
19
38
  /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
20
39
  * every other line (comments, blank lines, unrelated keys) — and
@@ -16,35 +16,53 @@ function provenancePath(agentName) {
16
16
  return join(agentDir(agentName), '.loopengine-abilities.json');
17
17
  }
18
18
  /** Every env var any ability installed for `agentName` declared it
19
- * needs, deduplicated by name — ABILITIES.md's own open question on
20
- * cross-ability name collisions applies here too (one .env per
21
- * *project*, not per agent, so two unrelated abilities declaring the
22
- * same name can't actually be told apart; the first one seen wins the
23
- * description shown). `set` is read live off `process.env`, not cached,
24
- * so it reflects whatever the last `setEnvVar` call — or a plain
25
- * restart picking up `.env` — actually did. */
19
+ * needs, merged by name. There's one .env per *project*, not per agent
20
+ * or per ability, so this can't actually resolve a genuine cross-ability
21
+ * name collision (two abilities declaring, say, "API_KEY" for two
22
+ * unrelated services still both read whichever single value ends up
23
+ * set — there is no per-ability slot to give them) — unlike a tool or
24
+ * skill name collision (see ability-manager.ts's namespacedToolName/
25
+ * namespacedSkillId), an env var name is a literal `process.env.X`
26
+ * reference baked into the ability's own code, not a label loopengine's
27
+ * own generated glue controls, so there's nothing here to safely rename.
28
+ * What this *can* do, and used to not: surface every ability that
29
+ * declares a given name (`abilityNames`, plural) instead of silently
30
+ * keeping only the first one seen and hiding the rest — turns a
31
+ * collision from invisible into something a human looking at the
32
+ * Environment tab can actually notice and judge (a real conflict
33
+ * needing one ability's own env var renamed, or a coincidence that's
34
+ * actually fine because both intentionally share one real credential).
35
+ * `secret` is true if *any* declaring ability marked it secret — the
36
+ * safer default, since treating a real secret as non-secret because a
37
+ * different ability's own declaration happened to be checked first
38
+ * would be the one direction genuinely worth avoiding. `set` is read
39
+ * live off `process.env`, not cached, so it reflects whatever the last
40
+ * `setEnvVar` call — or a plain restart picking up `.env` — actually
41
+ * did. */
26
42
  export function listDeclaredEnvVars(agentName) {
27
43
  const path = provenancePath(agentName);
28
44
  if (!existsSync(path))
29
45
  return [];
30
46
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
31
- const seen = new Set();
32
- const result = [];
47
+ const byName = new Map();
33
48
  for (const [abilityName, record] of Object.entries(provenance)) {
34
49
  for (const decl of record.env) {
35
- if (seen.has(decl.name))
50
+ const existing = byName.get(decl.name);
51
+ if (existing) {
52
+ existing.abilityNames.push(abilityName);
53
+ existing.secret = existing.secret || decl.secret === true;
36
54
  continue;
37
- seen.add(decl.name);
38
- result.push({
55
+ }
56
+ byName.set(decl.name, {
39
57
  name: decl.name,
40
58
  description: decl.description,
41
59
  secret: decl.secret === true,
42
- abilityName,
60
+ abilityNames: [abilityName],
43
61
  set: process.env[decl.name] !== undefined,
44
62
  });
45
63
  }
46
64
  }
47
- return result;
65
+ return [...byName.values()];
48
66
  }
49
67
  // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
50
68
  // --env-file-if-exists=.env (Node's own --env-file resolves relative to
@@ -37,18 +37,29 @@ export interface HttpToolSpec {
37
37
  safe?: boolean;
38
38
  }
39
39
  export declare function toCamelCase(snakeCase: string): string;
40
- export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
41
- /** addToolToIndex's own undo — removes the named tool's import line and
42
- * array entry. Best-effort, not "refuse rather than guess" like
43
- * addToolToIndex: this only ever runs against a tool ability-manager.ts
44
- * itself installed (so the exact shape addToolToIndex wrote is known),
45
- * and the caller (removeAbility) has already deleted the tool's .ts
46
- * file — leaving a stale entry behind, the previous behavior, means a
47
- * dangling import that fails the next build, which is strictly worse
48
- * than a no-op here if the file turns out not to match (hand-edited
49
- * since). No-ops (rather than throwing) when the file is missing
50
- * entirely, or the expected import/array-entry shape isn't found. */
51
- export declare function removeToolFromIndex(toolsIndexPath: string, toolFileName: string, exportName: string): void;
40
+ export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, importSpecifier: string, arrayExpression?: string, preamble?: string): void;
41
+ /** addToolToIndex's own undo — removes the named tool's import line,
42
+ * wrapper `const` (if a namespaced install added one), and array entry.
43
+ * Best-effort, not "refuse rather than guess" like addToolToIndex: this
44
+ * only ever runs against a tool ability-manager.ts itself installed (so
45
+ * the exact shape addToolToIndex wrote is known), and the caller
46
+ * (removeAbility) has already deleted the tool's .ts file — leaving a
47
+ * stale entry behind, the previous behavior, means a dangling import
48
+ * that fails the next build, which is strictly worse than a no-op here
49
+ * if the file turns out not to match (hand-edited since). No-ops (rather
50
+ * than throwing) when the file is missing entirely, or the expected
51
+ * import/array-entry shape isn't found.
52
+ *
53
+ * `arrayExpression` (not the raw import specifier) — same value
54
+ * addToolToIndex's own `arrayExpression` was called with, since that's
55
+ * what actually appears in the array literal and (for a namespaced
56
+ * install) names the wrapper `const` too. The import line is matched by
57
+ * `toolFileName` alone, not by the exact imported binding: a namespaced
58
+ * install aliases its import (`X as Y`), so matching on the file path —
59
+ * which uniquely identifies this one tool either way — is what makes
60
+ * this work for both shapes without the caller having to say which one
61
+ * it originally was. */
62
+ export declare function removeToolFromIndex(toolsIndexPath: string, toolFileName: string, arrayExpression: string): void;
52
63
  /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
53
64
  * that agent's tools/index.ts, then imports the file it just wrote and
54
65
  * returns the real, callable ToolDefinition object — so the route
@@ -179,13 +179,29 @@ ${responseHandling} },
179
179
  // across every real example in this repo (agents/customer-service/
180
180
  // tools/index.ts, create-loopengine's own template) — a handful of
181
181
  // named imports, then one `export const tools: ToolDefinition[] = [...]`
182
- // array literal. This patches that exact shape with two small string
183
- // edits (one new import line, one new array entry) rather than a full
184
- // TS-AST rewrite; if the file has been hand-edited into something this
185
- // pattern doesn't recognize, it refuses instead of risking corrupting it
186
- // — same "refuse rather than guess" discipline agent-file-admin.ts's own
187
- // doc comment already establishes for index.ts edits.
188
- export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
182
+ // array literal. This patches that exact shape with small string edits
183
+ // (one new import line, one new array entry, and — only for a namespaced
184
+ // ability install, see ability-manager.ts's namespacedToolName — one
185
+ // `const` line between them) rather than a full TS-AST rewrite; if the
186
+ // file has been hand-edited into something this pattern doesn't
187
+ // recognize, it refuses instead of risking corrupting it — same "refuse
188
+ // rather than guess" discipline agent-file-admin.ts's own doc comment
189
+ // already establishes for index.ts edits.
190
+ //
191
+ // `importSpecifier` and `arrayExpression` are deliberately separate
192
+ // parameters, not always the same string: the common case (this
193
+ // function's only caller until ability-manager.ts's own namespacing)
194
+ // has them identical (a bare `import { X }`, then bare `X` in the
195
+ // array), but a namespaced tool install needs an *aliased* import (two
196
+ // abilities' own files can both export a same-named top-level `const` —
197
+ // e.g. both literally `webSearch` — which would collide as two
198
+ // identically-named imports in this one file even though the source
199
+ // files differ) feeding into a small wrapper `const` that overrides the
200
+ // object's own `name` field (aliasing the import only renames the JS
201
+ // binding, never the `ToolDefinition.name` string the model actually
202
+ // calls by) — `preamble` is that wrapper line, and `arrayExpression` is
203
+ // its name, not the raw import's.
204
+ export function addToolToIndex(toolsIndexPath, toolFileName, importSpecifier, arrayExpression = importSpecifier, preamble) {
189
205
  const source = readFileSync(toolsIndexPath, 'utf8');
190
206
  const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
191
207
  if (!arrayMatch) {
@@ -201,40 +217,54 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
201
217
  .split(',')
202
218
  .map((e) => e.trim())
203
219
  .filter(Boolean);
204
- if (arrayEntries.includes(exportName))
220
+ if (arrayEntries.includes(arrayExpression))
205
221
  return;
206
- const importLine = `import { ${exportName} } from './${toolFileName}.js'\n`;
222
+ const importLine = `import { ${importSpecifier} } from './${toolFileName}.js'\n`;
207
223
  const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
208
224
  const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
209
- const withImport = source.slice(0, insertAt) + importLine + source.slice(insertAt);
210
- const newEntries = [...arrayEntries, exportName].join(', ');
225
+ const preambleBlock = preamble ? `${preamble}\n` : '';
226
+ const withImport = source.slice(0, insertAt) + importLine + preambleBlock + source.slice(insertAt);
227
+ const newEntries = [...arrayEntries, arrayExpression].join(', ');
211
228
  const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
212
229
  writeFileSync(toolsIndexPath, withEntry);
213
230
  }
214
- /** addToolToIndex's own undo — removes the named tool's import line and
215
- * array entry. Best-effort, not "refuse rather than guess" like
216
- * addToolToIndex: this only ever runs against a tool ability-manager.ts
217
- * itself installed (so the exact shape addToolToIndex wrote is known),
218
- * and the caller (removeAbility) has already deleted the tool's .ts
219
- * file — leaving a stale entry behind, the previous behavior, means a
220
- * dangling import that fails the next build, which is strictly worse
221
- * than a no-op here if the file turns out not to match (hand-edited
222
- * since). No-ops (rather than throwing) when the file is missing
223
- * entirely, or the expected import/array-entry shape isn't found. */
224
- export function removeToolFromIndex(toolsIndexPath, toolFileName, exportName) {
231
+ /** addToolToIndex's own undo — removes the named tool's import line,
232
+ * wrapper `const` (if a namespaced install added one), and array entry.
233
+ * Best-effort, not "refuse rather than guess" like addToolToIndex: this
234
+ * only ever runs against a tool ability-manager.ts itself installed (so
235
+ * the exact shape addToolToIndex wrote is known), and the caller
236
+ * (removeAbility) has already deleted the tool's .ts file — leaving a
237
+ * stale entry behind, the previous behavior, means a dangling import
238
+ * that fails the next build, which is strictly worse than a no-op here
239
+ * if the file turns out not to match (hand-edited since). No-ops (rather
240
+ * than throwing) when the file is missing entirely, or the expected
241
+ * import/array-entry shape isn't found.
242
+ *
243
+ * `arrayExpression` (not the raw import specifier) — same value
244
+ * addToolToIndex's own `arrayExpression` was called with, since that's
245
+ * what actually appears in the array literal and (for a namespaced
246
+ * install) names the wrapper `const` too. The import line is matched by
247
+ * `toolFileName` alone, not by the exact imported binding: a namespaced
248
+ * install aliases its import (`X as Y`), so matching on the file path —
249
+ * which uniquely identifies this one tool either way — is what makes
250
+ * this work for both shapes without the caller having to say which one
251
+ * it originally was. */
252
+ export function removeToolFromIndex(toolsIndexPath, toolFileName, arrayExpression) {
225
253
  if (!existsSync(toolsIndexPath))
226
254
  return;
227
- const source = readFileSync(toolsIndexPath, 'utf8');
228
- const importLineRe = new RegExp(`^import \\{ ${exportName} \\} from '\\./${toolFileName}\\.js'\\n`, 'm');
229
- const withoutImport = source.replace(importLineRe, '');
230
- const arrayMatch = withoutImport.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
255
+ let source = readFileSync(toolsIndexPath, 'utf8');
256
+ const importLineRe = new RegExp(`^import \\{ [^}]+ \\} from '\\./${toolFileName}\\.js'\\n`, 'm');
257
+ source = source.replace(importLineRe, '');
258
+ const preambleRe = new RegExp(`^const ${arrayExpression}: ToolDefinition = \\{[^\\n]*\\}\\n`, 'm');
259
+ source = source.replace(preambleRe, '');
260
+ const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
231
261
  if (!arrayMatch)
232
262
  return;
233
263
  const remainingEntries = arrayMatch[1]
234
264
  .split(',')
235
265
  .map((e) => e.trim())
236
- .filter((e) => e && e !== exportName);
237
- const withEntry = withoutImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${remainingEntries.join(', ')}]`);
266
+ .filter((e) => e && e !== arrayExpression);
267
+ const withEntry = source.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${remainingEntries.join(', ')}]`);
238
268
  writeFileSync(toolsIndexPath, withEntry);
239
269
  }
240
270
  // The spec is persisted alongside the generated .ts file, under the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.24",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",