loopengine 0.1.22 → 0.1.27

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/dist/bin/cli.js CHANGED
@@ -13,7 +13,7 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { spawn } from 'node:child_process';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { installPackage, upgradePackage, removePackage } from './package-manager.js';
16
+ import { installAbility, upgradeAbility, removeAbility } from './ability-manager.js';
17
17
  const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
18
18
  export class AgentNameError extends Error {
19
19
  }
@@ -231,7 +231,7 @@ async function requireAdapterFile(relPath) {
231
231
  process.exitCode = 1;
232
232
  return false;
233
233
  }
234
- // Shared by add-package/upgrade-package/remove-package below — pulls a
234
+ // Shared by add-ability/upgrade-ability/remove-ability below — pulls a
235
235
  // `--flag value` pair out of argv, same inline style `run`'s own
236
236
  // `--input` handling already uses, factored out only because three
237
237
  // commands need the identical `--agent <name>` extraction (a single use
@@ -301,22 +301,22 @@ async function main() {
301
301
  console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
302
302
  return;
303
303
  }
304
- // Installs a loopengine package (see PACKAGES.md) — a bundle of tool
304
+ // Installs a loopengine ability (see ABILITIES.md) — a bundle of tool
305
305
  // files, skill directories, and actauth rules published as an ordinary
306
306
  // npm package (public, private-registry, git, or a local file: path),
307
307
  // copied into the target agent's own tree rather than added as a
308
- // node_modules import (see package-manager.ts's own header comment for
308
+ // node_modules import (see ability-manager.ts's own header comment for
309
309
  // why). <spec> is whatever `npm pack` itself accepts.
310
- if (command === 'add-package') {
310
+ if (command === 'add-ability') {
311
311
  const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
312
312
  const [spec] = withoutAgent;
313
313
  if (!spec || !agent) {
314
- console.error('Usage: loopengine add-package <spec> --agent <name>');
314
+ console.error('Usage: loopengine add-ability <spec> --agent <name>');
315
315
  process.exitCode = 1;
316
316
  return;
317
317
  }
318
318
  try {
319
- const { installed } = await installPackage(agent, spec);
319
+ const { installed } = await installAbility(agent, spec);
320
320
  console.log(`Installed into agents/${agent}/:`);
321
321
  for (const p of installed)
322
322
  console.log(` ${p}`);
@@ -330,23 +330,23 @@ async function main() {
330
330
  }
331
331
  return;
332
332
  }
333
- // Pulls in whatever <packageName> (or --spec, if the package moved)
333
+ // Pulls in whatever <abilityName> (or --spec, if the ability moved)
334
334
  // currently resolves to, three-way-merging each tool/skill file it
335
335
  // manages against what changed upstream since install — a hand-edit
336
336
  // survives a clean merge; a real conflict leaves <<<<<<< markers to
337
337
  // resolve by hand, same as create-loopengine upgrade already does for
338
338
  // template files.
339
- if (command === 'upgrade-package') {
339
+ if (command === 'upgrade-ability') {
340
340
  const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
341
341
  const { value: spec, rest: withoutSpec } = extractFlagValue(withoutAgent, '--spec');
342
- const [packageName] = withoutSpec;
343
- if (!packageName || !agent) {
344
- console.error('Usage: loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
342
+ const [abilityName] = withoutSpec;
343
+ if (!abilityName || !agent) {
344
+ console.error('Usage: loopengine upgrade-ability <abilityName> --agent <name> [--spec <spec>]');
345
345
  process.exitCode = 1;
346
346
  return;
347
347
  }
348
348
  try {
349
- const { files } = await upgradePackage(agent, packageName, spec ? { spec } : {});
349
+ const { files } = await upgradeAbility(agent, abilityName, spec ? { spec } : {});
350
350
  for (const f of files)
351
351
  console.log(` ${f.status.padEnd(9)}${f.path}`);
352
352
  const conflicts = files.filter((f) => f.status === 'conflict');
@@ -358,20 +358,20 @@ async function main() {
358
358
  }
359
359
  return;
360
360
  }
361
- // Removes a package's tool/skill files and actauth rules — refuses a
361
+ // Removes an ability's tool/skill files and actauth rules — refuses a
362
362
  // file whose content no longer matches what was recorded at
363
363
  // install/last-upgrade (a hand-edit since) unless --force is given.
364
- if (command === 'remove-package') {
364
+ if (command === 'remove-ability') {
365
365
  const { value: agent, rest: withoutAgent } = extractFlagValue(rest, '--agent');
366
366
  const { present: force, rest: withoutForce } = extractFlagPresence(withoutAgent, '--force');
367
- const [packageName] = withoutForce;
368
- if (!packageName || !agent) {
369
- console.error('Usage: loopengine remove-package <packageName> --agent <name> [--force]');
367
+ const [abilityName] = withoutForce;
368
+ if (!abilityName || !agent) {
369
+ console.error('Usage: loopengine remove-ability <abilityName> --agent <name> [--force]');
370
370
  process.exitCode = 1;
371
371
  return;
372
372
  }
373
373
  try {
374
- const { removed, refused } = removePackage(agent, packageName, force);
374
+ const { removed, refused } = removeAbility(agent, abilityName, force);
375
375
  for (const p of removed)
376
376
  console.log(` removed ${p}`);
377
377
  for (const p of refused)
@@ -463,9 +463,9 @@ async function main() {
463
463
  }
464
464
  console.error('Usage: loopengine add-agent <name>');
465
465
  console.error(' loopengine add-subagent <parent> <name>');
466
- console.error(' loopengine add-package <spec> --agent <name>');
467
- console.error(' loopengine upgrade-package <packageName> --agent <name> [--spec <spec>]');
468
- console.error(' loopengine remove-package <packageName> --agent <name> [--force]');
466
+ console.error(' loopengine add-ability <spec> --agent <name>');
467
+ console.error(' loopengine upgrade-ability <abilityName> --agent <name> [--spec <spec>]');
468
+ console.error(' loopengine remove-ability <abilityName> --agent <name> [--force]');
469
469
  console.error(' loopengine run <agent> [--session <id>] "<message>"');
470
470
  console.error(' loopengine serve');
471
471
  console.error(' loopengine dev');
@@ -1110,9 +1110,9 @@ export const agentsConfigPageHtml = `<!doctype html>
1110
1110
  });
1111
1111
  }
1112
1112
 
1113
- // ---- Environment tab: every env var a package (see PACKAGES.md,
1114
- // bin/package-manager.ts) declared it needs for this agent, across
1115
- // every package installed — status only, a value is never shown once
1113
+ // ---- Environment tab: every env var an ability (see ABILITIES.md,
1114
+ // bin/ability-manager.ts) declared it needs for this agent, across
1115
+ // every ability installed — status only, a value is never shown once
1116
1116
  // set, matching env-admin.ts's own never-echo-a-secret rule. Lazily
1117
1117
  // loaded the same way Actauth is, for the same reason (avoid a
1118
1118
  // redundant fetch on every agent switch for a tab that isn't always
@@ -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.packageName) + '</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>' +
@@ -1136,9 +1145,9 @@ export const agentsConfigPageHtml = `<!doctype html>
1136
1145
  }
1137
1146
 
1138
1147
  function renderEnvConfigHtml(vars) {
1139
- if (!vars.length) return '<p class="hint">No installed package has declared any environment variables for this agent yet.</p>';
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>Package</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,22 +4,41 @@ export interface DeclaredEnvVar {
4
4
  name: string;
5
5
  description?: string;
6
6
  secret: boolean;
7
- packageName: 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
- /** Every env var any package installed for `agentName` declared it
11
- * needs, deduplicated by name — PACKAGES.md's own open question on
12
- * cross-package name collisions applies here too (one .env per
13
- * *project*, not per agent, so two unrelated packages 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. */
13
+ /** Every env var any ability installed for `agentName` declared it
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
21
40
  * applies it to *this* running process immediately via `process.env`,
22
- * so a newly-installed package's tools work without a restart. Callers
41
+ * so a newly-installed ability's tools work without a restart. Callers
23
42
  * (the PUT route in adapters/http.ts) are responsible for refusing to
24
43
  * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
25
44
  * function itself has no notion of HTTP auth, it just writes. */
@@ -1,6 +1,6 @@
1
- // Backs the Admin UI's "Environment" section — every env var a package
2
- // (see PACKAGES.md, bin/package-manager.ts) declared as required,
3
- // across every package installed for an agent, with set/not-set status
1
+ // Backs the Admin UI's "Environment" section — every env var an ability
2
+ // (see ABILITIES.md, bin/ability-manager.ts) declared as required,
3
+ // across every ability installed for an agent, with set/not-set status
4
4
  // only. A value marked `secret` is never echoed back once set — same
5
5
  // never-echo-a-secret rule web/http-tool-admin.ts's own `{{ENV_VAR}}`
6
6
  // header handling already establishes for a tool's own secrets.
@@ -13,38 +13,56 @@ export class EnvVarNameError extends Error {
13
13
  // validates a {{ENV_VAR}} header reference against.
14
14
  const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
15
15
  function provenancePath(agentName) {
16
- return join(agentDir(agentName), '.loopengine-packages.json');
16
+ return join(agentDir(agentName), '.loopengine-abilities.json');
17
17
  }
18
- /** Every env var any package installed for `agentName` declared it
19
- * needs, deduplicated by name — PACKAGES.md's own open question on
20
- * cross-package name collisions applies here too (one .env per
21
- * *project*, not per agent, so two unrelated packages 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. */
18
+ /** Every env var any ability installed for `agentName` declared it
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 = [];
33
- for (const [packageName, record] of Object.entries(provenance)) {
47
+ const byName = new Map();
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
- packageName,
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
@@ -68,7 +86,7 @@ function serializeEnvValue(value) {
68
86
  /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
69
87
  * every other line (comments, blank lines, unrelated keys) — and
70
88
  * applies it to *this* running process immediately via `process.env`,
71
- * so a newly-installed package's tools work without a restart. Callers
89
+ * so a newly-installed ability's tools work without a restart. Callers
72
90
  * (the PUT route in adapters/http.ts) are responsible for refusing to
73
91
  * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
74
92
  * function itself has no notion of HTTP auth, it just writes. */
@@ -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 package-manager.ts
44
- * itself installed (so the exact shape addToolToIndex wrote is known),
45
- * and the caller (removePackage) 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,20 +179,36 @@ ${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) {
192
208
  throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
193
209
  }
194
- // Idempotent — a caller that adds the same tool twice (e.g. package-
195
- // manager.ts's removePackage not having cleaned up this same file
210
+ // Idempotent — a caller that adds the same tool twice (e.g. ability-
211
+ // manager.ts's removeAbility not having cleaned up this same file
196
212
  // before a re-add, or simply calling this twice by mistake) gets a
197
213
  // no-op the second time instead of a duplicate `import { X }`
198
214
  // declaration, which is a TypeScript syntax error ("Duplicate
@@ -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 package-manager.ts
217
- * itself installed (so the exact shape addToolToIndex wrote is known),
218
- * and the caller (removePackage) 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.22",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -23,8 +23,8 @@
23
23
  "dist/index.d.ts",
24
24
  "dist/bin/cli.js",
25
25
  "dist/bin/cli.d.ts",
26
- "dist/bin/package-manager.js",
27
- "dist/bin/package-manager.d.ts",
26
+ "dist/bin/ability-manager.js",
27
+ "dist/bin/ability-manager.d.ts",
28
28
  "dist/core/run-agent.js",
29
29
  "dist/core/run-agent.d.ts",
30
30
  "dist/core/loop-events.js",