loopengine 0.1.2 → 0.1.4

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.
@@ -0,0 +1,23 @@
1
+ export declare class SkillInvalidIdError extends Error {
2
+ }
3
+ export declare class SkillNotFoundError extends Error {
4
+ }
5
+ export interface SkillContent {
6
+ id: string;
7
+ description: string;
8
+ body: string;
9
+ }
10
+ export declare function readSkill(agentName: string, skillId: string): SkillContent;
11
+ /** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
12
+ * place if it does — same "this is the new content, not a diff to
13
+ * merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
14
+ * (as `name`) and `description`; any *other* frontmatter keys a skill
15
+ * might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
16
+ * intentionally not preserved here, since this admin UI has no field for
17
+ * them. A skill that needs those is still editable by hand — this just
18
+ * isn't the tool for it. */
19
+ export declare function writeSkill(agentName: string, skillId: string, content: {
20
+ description: string;
21
+ body: string;
22
+ }): void;
23
+ export declare function deleteSkill(agentName: string, skillId: string): void;
@@ -0,0 +1,67 @@
1
+ // Lets an operator add, edit, or remove a skill from the Skills tab in
2
+ // /agents/config, instead of hand-writing agents/<name>/skills/<id>/
3
+ // SKILL.md directly. Deliberately scoped to *flat* skills only — a
4
+ // nested skill (e.g. agents/<name>/skills/deploy/web/SKILL.md, which
5
+ // SkillGarden's own discoverSkillFiles namespaces as 'deploy:web') isn't
6
+ // addressable through this admin surface; its id would need to carry a
7
+ // '/' through a single URL path segment, which either means encoding
8
+ // tricks or a second routing scheme, for a nesting feature nothing in
9
+ // this repo's own example agents actually uses. Read-only display of a
10
+ // nested skill (via describeAgent's own skills list) still works fine —
11
+ // only adding/editing/removing one through this UI is out of scope.
12
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { parseSkillFile } from 'skillgarden';
15
+ import { agentDir } from '../core/gateway-tools.js';
16
+ export class SkillInvalidIdError extends Error {
17
+ }
18
+ export class SkillNotFoundError extends Error {
19
+ }
20
+ // Same character set agent/subagent names are already validated against
21
+ // elsewhere in this repo (bin/cli.ts's NAME_PATTERN) — a skill id becomes a
22
+ // directory name on disk, so this is what keeps it from ever containing
23
+ // '/', '..', or anything else that could resolve outside its own
24
+ // skills/ folder.
25
+ const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
26
+ function requireValidSkillId(skillId) {
27
+ if (!SKILL_ID_PATTERN.test(skillId)) {
28
+ throw new SkillInvalidIdError(`Invalid skill id '${skillId}' — must be lowercase, alphanumeric, hyphen-separated (e.g. "summarize-files"); nested skills aren't editable through this admin UI.`);
29
+ }
30
+ }
31
+ function skillsDir(agentName) {
32
+ return join(agentDir(agentName), 'skills');
33
+ }
34
+ function skillFilePath(agentName, skillId) {
35
+ requireValidSkillId(skillId);
36
+ return join(skillsDir(agentName), skillId, 'SKILL.md');
37
+ }
38
+ export function readSkill(agentName, skillId) {
39
+ const path = skillFilePath(agentName, skillId);
40
+ if (!existsSync(path)) {
41
+ throw new SkillNotFoundError(`No skill '${skillId}' for '${agentName}'.`);
42
+ }
43
+ const { frontmatter, body } = parseSkillFile(readFileSync(path, 'utf8'), skillId);
44
+ return { id: skillId, description: frontmatter.description ?? '', body };
45
+ }
46
+ /** Creates the skill if `skillId` doesn't exist yet, or overwrites it in
47
+ * place if it does — same "this is the new content, not a diff to
48
+ * merge" semantics as a PUT. Frontmatter is regenerated from `skillId`
49
+ * (as `name`) and `description`; any *other* frontmatter keys a skill
50
+ * might have (e.g. `paths` — see SkillFrontmatter's own doc comment) are
51
+ * intentionally not preserved here, since this admin UI has no field for
52
+ * them. A skill that needs those is still editable by hand — this just
53
+ * isn't the tool for it. */
54
+ export function writeSkill(agentName, skillId, content) {
55
+ const path = skillFilePath(agentName, skillId);
56
+ const description = content.description.replace(/\n/g, ' ').trim();
57
+ const frontmatter = `---\nname: ${skillId}\ndescription: ${JSON.stringify(description)}\n---\n\n`;
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ writeFileSync(path, frontmatter + content.body.trim() + '\n');
60
+ }
61
+ export function deleteSkill(agentName, skillId) {
62
+ const path = skillFilePath(agentName, skillId);
63
+ if (!existsSync(path)) {
64
+ throw new SkillNotFoundError(`No skill '${skillId}' for '${agentName}'.`);
65
+ }
66
+ rmSync(dirname(path), { recursive: true, force: true });
67
+ }
@@ -0,0 +1,35 @@
1
+ import { WebchatApprover, type PendingApproval } from 'actauth';
2
+ /** Every WebchatApprover a request might end up using should be created
3
+ * through this, not `new WebchatApprover()` directly — the id it hands out
4
+ * on each new pending approval is otherwise unreachable from
5
+ * decideApproval()/listApprovals() below. Both adapters/http.ts routes
6
+ * call this once per turn, with that turn's own sessionId: the streaming
7
+ * route wires onPending to an SSE event on that exact response so the
8
+ * approval popup appears inline in the conversation that's actually
9
+ * blocked on it; the plain route wires it to resolve the early-return
10
+ * race handleMessages uses instead of blocking indefinitely (see its own
11
+ * doc comment) — same reason, no HTTP request should hang on a human
12
+ * with nothing telling the caller that's what's happening. */
13
+ export declare function createTrackedApprover(sessionId: string, onPending?: (approval: PendingApproval) => void): WebchatApprover;
14
+ /** Every currently-pending approval, from any tracked instance. Unfiltered
15
+ * (both omitted) returns every approval pending anywhere in this process,
16
+ * across every agent, tenant, and session — real uses (an operator's own
17
+ * admin view, say) should filter; both agent and session scoping are
18
+ * exact (agent from actauth's own Scope, already on every PendingApproval;
19
+ * session from sessionByApprover, always known — see its own doc
20
+ * comment). */
21
+ export declare function listApprovals(filter?: {
22
+ agent?: string;
23
+ sessionId?: string;
24
+ }): PendingApproval[];
25
+ /** Routes to whichever WebchatApprover instance actually owns `id` — the
26
+ * caller doesn't need to know which turn/session created it. */
27
+ export declare function decideApproval(id: string, approved: boolean): boolean;
28
+ /** Peek at a pending approval's own entry and owning session, without
29
+ * deciding it — adapters/http.ts needs this *before* calling
30
+ * decideApproval() above, to know which turn's early-return race (see its
31
+ * own sessionTurns) to resume once this one's decided. */
32
+ export declare function findApproval(id: string): {
33
+ approval: PendingApproval;
34
+ sessionId: string;
35
+ } | undefined;
@@ -0,0 +1,88 @@
1
+ // adapters/http.ts's wiring around actauth's WebchatApprover (see its own
2
+ // approvers.ts) — one process can have several WebchatApprover instances
3
+ // live at once (see createTrackedApprover's own doc comment for why),
4
+ // so POST /approvals/:id/approve|deny needs to know which instance
5
+ // actually owns a given pending id before it can call decide() on it.
6
+ // That routing table is what this file owns; the approver class itself
7
+ // stays in actauth, generic and framework-agnostic.
8
+ import { WebchatApprover } from 'actauth';
9
+ const approversById = new Map();
10
+ // Which session each *instance* is serving — not each pending approval,
11
+ // because a single WebchatApprover instance only ever needs to serve one
12
+ // session's worth of calls: both adapters/http.ts routes now create a
13
+ // fresh tracked approver per call (see createTrackedApprover's own doc
14
+ // comment), so this is always known, not best-effort. A WeakMap, not a
15
+ // Map, so an old instance (nothing else references it once its own
16
+ // approvals are gone) doesn't get held alive forever just for this
17
+ // bookkeeping.
18
+ const sessionByApprover = new WeakMap();
19
+ /** Every WebchatApprover a request might end up using should be created
20
+ * through this, not `new WebchatApprover()` directly — the id it hands out
21
+ * on each new pending approval is otherwise unreachable from
22
+ * decideApproval()/listApprovals() below. Both adapters/http.ts routes
23
+ * call this once per turn, with that turn's own sessionId: the streaming
24
+ * route wires onPending to an SSE event on that exact response so the
25
+ * approval popup appears inline in the conversation that's actually
26
+ * blocked on it; the plain route wires it to resolve the early-return
27
+ * race handleMessages uses instead of blocking indefinitely (see its own
28
+ * doc comment) — same reason, no HTTP request should hang on a human
29
+ * with nothing telling the caller that's what's happening. */
30
+ export function createTrackedApprover(sessionId, onPending) {
31
+ let approver;
32
+ approver = new WebchatApprover({
33
+ onPending: (approval) => {
34
+ approversById.set(approval.id, approver);
35
+ onPending?.(approval);
36
+ },
37
+ onSettled: (id) => {
38
+ approversById.delete(id);
39
+ },
40
+ });
41
+ sessionByApprover.set(approver, sessionId);
42
+ return approver;
43
+ }
44
+ /** Every currently-pending approval, from any tracked instance. Unfiltered
45
+ * (both omitted) returns every approval pending anywhere in this process,
46
+ * across every agent, tenant, and session — real uses (an operator's own
47
+ * admin view, say) should filter; both agent and session scoping are
48
+ * exact (agent from actauth's own Scope, already on every PendingApproval;
49
+ * session from sessionByApprover, always known — see its own doc
50
+ * comment). */
51
+ export function listApprovals(filter) {
52
+ const results = [];
53
+ for (const approver of new Set(approversById.values())) {
54
+ const sessionId = sessionByApprover.get(approver);
55
+ for (const approval of approver.list()) {
56
+ if (filter?.agent && approval.scope.agent !== filter.agent)
57
+ continue;
58
+ if (filter?.sessionId && sessionId !== filter.sessionId)
59
+ continue;
60
+ results.push(approval);
61
+ }
62
+ }
63
+ return results;
64
+ }
65
+ /** Routes to whichever WebchatApprover instance actually owns `id` — the
66
+ * caller doesn't need to know which turn/session created it. */
67
+ export function decideApproval(id, approved) {
68
+ const approver = approversById.get(id);
69
+ if (!approver)
70
+ return false;
71
+ return approver.decide(id, approved);
72
+ }
73
+ /** Peek at a pending approval's own entry and owning session, without
74
+ * deciding it — adapters/http.ts needs this *before* calling
75
+ * decideApproval() above, to know which turn's early-return race (see its
76
+ * own sessionTurns) to resume once this one's decided. */
77
+ export function findApproval(id) {
78
+ const approver = approversById.get(id);
79
+ if (!approver)
80
+ return undefined;
81
+ const approval = approver.list().find((a) => a.id === id);
82
+ if (!approval)
83
+ return undefined;
84
+ const sessionId = sessionByApprover.get(approver);
85
+ if (sessionId === undefined)
86
+ return undefined;
87
+ return { approval, sessionId };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -58,6 +58,9 @@
58
58
  "dist/core/http-notify-triggers",
59
59
  "dist/core/system-tools",
60
60
  "dist/core/system-skills",
61
+ "dist/core/durable-approvals.js",
62
+ "dist/core/durable-approvals.d.ts",
63
+ "dist/web",
61
64
  "protocol/loop-event.schema.json",
62
65
  "PROTOCOL.md",
63
66
  "LICENSE"
@@ -77,12 +80,12 @@
77
80
  "sessionknit": "^0.0.2",
78
81
  "skillgarden": "^0.0.5",
79
82
  "toollane": "^0.0.1",
83
+ "typescript": "^5.7.0",
80
84
  "yaml": "^2.6.0"
81
85
  },
82
86
  "devDependencies": {
83
87
  "@types/node": "^26.2.0",
84
88
  "tsx": "^4.19.0",
85
- "typescript": "^5.7.0",
86
89
  "vitest": "^2.1.9"
87
90
  }
88
91
  }