loopengine 0.1.13 → 0.1.15

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.
@@ -29,6 +29,14 @@ export interface PackageManifest {
29
29
  * same role .create-loopengine.json already plays for template files. */
30
30
  export interface InstalledPackageRecord {
31
31
  version: string;
32
+ /** The exact spec (bare registry name, "name@version", a git+ssh/
33
+ * git+https URL with a #committish, a file: path, ...) last used to
34
+ * successfully install or upgrade this package — see
35
+ * oldContentSpec's own doc comment for why upgradePackage needs this
36
+ * stored verbatim rather than reconstructed from `packageName`.
37
+ * Optional only because a package installed before this field existed
38
+ * has no recorded value — see oldContentSpec's own fallback. */
39
+ spec?: string;
32
40
  tools: string[];
33
41
  skills: string[];
34
42
  actauthRules: string[];
@@ -99,14 +107,14 @@ export interface UpgradeFileResult {
99
107
  * to `packageName`, i.e. "latest") currently resolves to. Tool and skill
100
108
  * files get a real three-way merge (mine = current file, possibly
101
109
  * hand-edited since install; base = the version recorded at
102
- * install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
103
- * newly fetched) — identical technique to `create-loopengine upgrade`,
104
- * just applied to package-managed files instead of template files.
105
- * actauth rules upgrade per-rule instead: the target actauth.yml holds
106
- * rules from other packages and hand-written ones too, so there's no
107
- * coherent "whole file" base/theirs to merge — a rule unchanged since
108
- * install updates cleanly; a hand-edited one is left alone and reported
109
- * as a conflict rather than overwritten. */
110
+ * install/last-upgrade, refetched via oldContentSpec; theirs = newly
111
+ * fetched) — identical technique to `create-loopengine upgrade`, just
112
+ * applied to package-managed files instead of template files. actauth
113
+ * rules upgrade per-rule instead: the target actauth.yml holds rules
114
+ * from other packages and hand-written ones too, so there's no coherent
115
+ * "whole file" base/theirs to merge — a rule unchanged since install
116
+ * updates cleanly; a hand-edited one is left alone and reported as a
117
+ * conflict rather than overwritten. */
110
118
  export declare function upgradePackage(agentName: string, packageName: string, options?: FetchOptions & {
111
119
  spec?: string;
112
120
  }): Promise<{
@@ -24,7 +24,7 @@ import { tmpdir } from 'node:os';
24
24
  import { parse as parseYaml } from 'yaml';
25
25
  import semver from 'semver';
26
26
  import { agentDir } from '../core/gateway-tools.js';
27
- import { addToolToIndex, toCamelCase } from '../web/http-tool-admin.js';
27
+ import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
28
28
  import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
29
29
  export class PackageManifestError extends Error {
30
30
  }
@@ -283,6 +283,7 @@ export async function installPackage(agentName, spec, options = {}) {
283
283
  }
284
284
  provenance[manifest.name] = {
285
285
  version: manifest.version,
286
+ spec,
286
287
  tools: toolNames,
287
288
  skills: skillIds,
288
289
  actauthRules: rules.map((r) => r.name),
@@ -321,18 +322,53 @@ function mergeFile(mine, base, theirs) {
321
322
  rmSync(scratchDir, { recursive: true, force: true });
322
323
  return { merged, conflicted };
323
324
  }
325
+ // A git+ssh/git+https/git: URL, a plain https: tarball URL, or a local
326
+ // file:/relative/absolute path all already pin to exact, immutable
327
+ // content on their own (a #committish, or the file/tarball's own
328
+ // content) — appending "@version" to one of these doesn't select an
329
+ // older version the way it does for a registry specifier, it corrupts
330
+ // the spec. Confirmed live: `npm pack 'git+file://...#v1.0.0@1.0.0'`
331
+ // fails outright ("The git reference could not be found... pathspec
332
+ // 'v1.0.0@1.0.0'"), it doesn't fall back to resolving just the tag.
333
+ const PINNED_SPEC = /^(git\+|git:|https?:|file:|\.\.?\/|\/)/;
334
+ /** What to fetch to reconstruct the exact content that was installed or
335
+ * last upgraded to, for use as the three-way merge's `base` — as
336
+ * distinct from `spec`, which is what the *new* content resolves to.
337
+ * A plain registry specifier (bare or scoped name, with or without its
338
+ * own "@version") is repinned to `version` — the manifest's own
339
+ * declared version at that install/upgrade, which is what actually got
340
+ * written to disk, regardless of whether the range originally given
341
+ * would still resolve there today. A git/file/URL spec is returned
342
+ * as-is — see PINNED_SPEC's own doc comment for why appending "@version"
343
+ * to one of those breaks instead of pinning. `recordedSpec` is only
344
+ * absent for a package installed before InstalledPackageRecord.spec
345
+ * existed; falling back to `packageName` there reproduces this
346
+ * function's own old (buggy for a git/file install) behavior exactly —
347
+ * no worse than before, and only for a package that hasn't upgraded
348
+ * since. */
349
+ function oldContentSpec(recordedSpec, packageName, version) {
350
+ const base = recordedSpec ?? packageName;
351
+ if (PINNED_SPEC.test(base))
352
+ return base;
353
+ // Strip any version/tag the spec already carries (a scoped name's own
354
+ // leading '@' isn't this — only a second '@' after the name is) before
355
+ // repinning, so re-upgrading an already-version-pinned install doesn't
356
+ // produce a doubled-up "name@1.0.0@1.0.0".
357
+ const bareName = base.startsWith('@') ? `@${base.slice(1).split('@')[0]}` : base.split('@')[0];
358
+ return `${bareName}@${version}`;
359
+ }
324
360
  /** Upgrades an already-installed package to whatever `spec` (defaulting
325
361
  * to `packageName`, i.e. "latest") currently resolves to. Tool and skill
326
362
  * files get a real three-way merge (mine = current file, possibly
327
363
  * hand-edited since install; base = the version recorded at
328
- * install/last-upgrade, refetched via `<spec>@<oldVersion>`; theirs =
329
- * newly fetched) — identical technique to `create-loopengine upgrade`,
330
- * just applied to package-managed files instead of template files.
331
- * actauth rules upgrade per-rule instead: the target actauth.yml holds
332
- * rules from other packages and hand-written ones too, so there's no
333
- * coherent "whole file" base/theirs to merge — a rule unchanged since
334
- * install updates cleanly; a hand-edited one is left alone and reported
335
- * as a conflict rather than overwritten. */
364
+ * install/last-upgrade, refetched via oldContentSpec; theirs = newly
365
+ * fetched) — identical technique to `create-loopengine upgrade`, just
366
+ * applied to package-managed files instead of template files. actauth
367
+ * rules upgrade per-rule instead: the target actauth.yml holds rules
368
+ * from other packages and hand-written ones too, so there's no coherent
369
+ * "whole file" base/theirs to merge — a rule unchanged since install
370
+ * updates cleanly; a hand-edited one is left alone and reported as a
371
+ * conflict rather than overwritten. */
336
372
  export async function upgradePackage(agentName, packageName, options = {}) {
337
373
  const fetch = options.fetchPackageDir ?? fetchPackageDir;
338
374
  const provenance = readProvenance(agentName);
@@ -341,7 +377,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
341
377
  throw new PackageNotInstalledError(`Package '${packageName}' isn't installed for '${agentName}'.`);
342
378
  }
343
379
  const spec = options.spec ?? packageName;
344
- const oldDir = fetch(`${spec}@${record.version}`);
380
+ const oldDir = fetch(oldContentSpec(record.spec, packageName, record.version));
345
381
  const newDir = fetch(spec);
346
382
  const oldManifest = readManifest(oldDir);
347
383
  const newManifest = readManifest(newDir);
@@ -416,7 +452,7 @@ export async function upgradePackage(agentName, packageName, options = {}) {
416
452
  updateActauthRule(agentName, ruleName, { scope: newRule.scope, tool: newRule.tool, decision: newRule.decision });
417
453
  results.push({ path, status: 'updated' });
418
454
  }
419
- provenance[packageName] = { ...record, version: newManifest.version, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
455
+ provenance[packageName] = { ...record, version: newManifest.version, spec, contentHashes: newContentHashes, env: newManifest.env ?? record.env };
420
456
  writeProvenance(agentName, provenance);
421
457
  return { files: results };
422
458
  }
@@ -448,6 +484,7 @@ export function removePackage(agentName, packageName, force = false) {
448
484
  const refused = [];
449
485
  const remainingTools = [];
450
486
  const remainingSkills = [];
487
+ const toolsIndexPath = join(agentDir(agentName), 'tools', 'index.ts');
451
488
  for (const toolName of record.tools) {
452
489
  const relPath = `tools/${toolName}.ts`;
453
490
  const fullPath = join(agentDir(agentName), relPath);
@@ -457,6 +494,14 @@ export function removePackage(agentName, packageName, force = false) {
457
494
  continue;
458
495
  }
459
496
  rmSync(fullPath, { force: true });
497
+ // Undoes installPackage's own addToolToIndex call — without this, a
498
+ // deleted tool's import survives in tools/index.ts, and either fails
499
+ // the next build (a dangling import to a file that no longer exists)
500
+ // or, worse, silently duplicates on a later add-package of the same
501
+ // tool (addToolToIndex used to have no idempotence check at all —
502
+ // now it does, but this is the actual fix: the stale entry shouldn't
503
+ // be there to begin with).
504
+ removeToolFromIndex(toolsIndexPath, toolName, toCamelCase(toolName));
460
505
  removed.push(relPath);
461
506
  }
462
507
  for (const skillId of record.skills) {
@@ -38,6 +38,17 @@ export interface HttpToolSpec {
38
38
  }
39
39
  export declare function toCamelCase(snakeCase: string): string;
40
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;
41
52
  /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
42
53
  * that agent's tools/index.ts, then imports the file it just wrote and
43
54
  * returns the real, callable ToolDefinition object — so the route
@@ -191,15 +191,52 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
191
191
  if (!arrayMatch) {
192
192
  throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
193
193
  }
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
196
+ // before a re-add, or simply calling this twice by mistake) gets a
197
+ // no-op the second time instead of a duplicate `import { X }`
198
+ // declaration, which is a TypeScript syntax error ("Duplicate
199
+ // identifier"), not just a harmless extra line.
200
+ const arrayEntries = arrayMatch[1]
201
+ .split(',')
202
+ .map((e) => e.trim())
203
+ .filter(Boolean);
204
+ if (arrayEntries.includes(exportName))
205
+ return;
194
206
  const importLine = `import { ${exportName} } from './${toolFileName}.js'\n`;
195
207
  const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
196
208
  const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
197
209
  const withImport = source.slice(0, insertAt) + importLine + source.slice(insertAt);
198
- const existingEntries = arrayMatch[1].trim();
199
- const newEntries = existingEntries ? `${existingEntries}, ${exportName}` : exportName;
210
+ const newEntries = [...arrayEntries, exportName].join(', ');
200
211
  const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
201
212
  writeFileSync(toolsIndexPath, withEntry);
202
213
  }
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) {
225
+ if (!existsSync(toolsIndexPath))
226
+ 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\[\] = \[([^\]]*)\]/);
231
+ if (!arrayMatch)
232
+ return;
233
+ const remainingEntries = arrayMatch[1]
234
+ .split(',')
235
+ .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(', ')}]`);
238
+ writeFileSync(toolsIndexPath, withEntry);
239
+ }
203
240
  // The spec is persisted alongside the generated .ts file, under the same
204
241
  // name — this is what lets the Tools tab's Edit button repopulate the
205
242
  // form later without reverse-parsing generated code back into a spec,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",