loopengine 0.1.86 → 0.1.88

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/README.md CHANGED
@@ -185,9 +185,12 @@ only their own message to tell whether it's a decision, and anything
185
185
  unclear is treated as a question. An approver can also change a request
186
186
  first ("make it $40"): the change is checked against the tool's schema
187
187
  and the permission rules and shown with **Approve as changed / Keep
188
- original / Deny**, and nothing runs until they approve it. A typed reply
189
- never runs anything by itself: a "yes" shows exactly what will run with
190
- a **Confirm** button, and only that click runs it. Arguments are shown
188
+ original / Deny**, and nothing runs until they approve it. A plain "yes"
189
+ ("approve", "go ahead", "好的", "はい", "sí", 👍…: the whole message,
190
+ matched against a fixed list of phrases in common languages, not by a
191
+ model) runs it; any other reply read as approval ("sure, send
192
+ it", "yes but check the amount") shows exactly what will run with a
193
+ **Confirm** button, and only that click runs it. Arguments are shown
191
194
  as labelled fields, with long text such as an email body quoted, not as
192
195
  JSON. Once approvers are listed, only their button clicks count too. The same tab lists every decision
193
196
  still waiting with Approve/Deny buttons (its count shows on the tab), and
@@ -15,9 +15,14 @@ export type ReplyDecision = {
15
15
  } | {
16
16
  decision: 'continue';
17
17
  };
18
- export type DecisionRequest = {
18
+ export type DecisionRequest =
19
+ /** `changeShown`: the person was just shown a proposed change to the
20
+ * arguments, waiting for their approval (our own fixed fact, not text
21
+ * anyone else wrote). */
22
+ {
19
23
  kind: 'approval';
20
24
  tool: string;
25
+ changeShown?: boolean;
21
26
  } | {
22
27
  kind: 'question';
23
28
  question: string;
@@ -18,7 +18,7 @@ function recordTool(kind) {
18
18
  }
19
19
  export function decisionPrompt(request) {
20
20
  const about = request.kind === 'approval'
21
- ? `An AI agent is waiting for approval to run the tool \`${request.tool}\`.`
21
+ ? `An AI agent is waiting for approval to run the tool \`${request.tool}\`.${request.changeShown ? ' The person was just shown a proposed change to its arguments, waiting for their approval: a plain yes or ok approves that change.' : ''}`
22
22
  : `An AI agent asked this question and is waiting for the answer: ${JSON.stringify(request.question)}${request.options?.length ? `\nOptions it offered: ${request.options.map((o) => JSON.stringify(o)).join(', ')}` : ''}`;
23
23
  const rules = request.kind === 'approval'
24
24
  ? [
@@ -55,6 +55,11 @@ export interface DecisionOutcome {
55
55
  /** Handles one person's message: a reply in a request's thread gets an
56
56
  * answer; anything else is left alone. */
57
57
  export declare function handleInboundMessage(message: InboundMessage, deps: MessagingDeps): Promise<void>;
58
+ /** Whether a reply is a plain approval: lower-cased, a leading @mention,
59
+ * emoji skin tones and "please" dropped, punctuation and spaces
60
+ * (full-width ones too) ignored, then compared with the fixed list. A
61
+ * trailing question mark keeps it from matching ("yes?"). */
62
+ export declare function isPlainApproval(text: string): boolean;
58
63
  /** A click on one of the engine's own buttons (Approve as changed, Keep
59
64
  * original, Confirm, Cancel) — the caller has already checked the
60
65
  * person may decide. Returns what the clicked message should now say. */
@@ -54,7 +54,7 @@ export async function handleInboundMessage(message, deps) {
54
54
  }
55
55
  const modelCall = deps.createModelCall();
56
56
  const request = link.kind === 'approval'
57
- ? { kind: 'approval', tool: item.tool }
57
+ ? { kind: 'approval', tool: item.tool, changeShown: Boolean(link.proposedArgs) }
58
58
  : {
59
59
  kind: 'question',
60
60
  question: typeof item.args.question === 'string' ? item.args.question : JSON.stringify(item.args),
@@ -94,6 +94,12 @@ export async function handleInboundMessage(message, deps) {
94
94
  }
95
95
  if (verdict.decision === 'approve') {
96
96
  const args = link.proposedArgs ?? item.args;
97
+ // A plain "yes" runs it; anything less clear that still reads as
98
+ // approval asks for a Confirm click first.
99
+ if (isPlainApproval(message.text)) {
100
+ await runApproved(deps, link, item.tool, args, who);
101
+ return;
102
+ }
97
103
  await connector.postChoice(message.conversation, threadTs, `Confirm to run \`${item.tool}\` with:\n${formatArgs(args, connector.escape, await deps.toolSchema(item.tool))}`, [
98
104
  { action: 'confirm', label: 'Confirm', style: 'primary', pendingId: link.pendingId },
99
105
  { action: 'cancel_confirm', label: 'Cancel', pendingId: link.pendingId },
@@ -133,6 +139,58 @@ export async function handleInboundMessage(message, deps) {
133
139
  await say(reply);
134
140
  });
135
141
  }
142
+ // Whole messages that approve and nothing else, in the languages people
143
+ // most often reply in. Matched exactly after the clean-up in
144
+ // isPlainApproval, so a condition or a second thought ("yes but…",
145
+ // "好的,但是…") never counts as plain; a phrase that isn't here just gets
146
+ // a Confirm click. Words that can also mean "hmm", "understood" or "no
147
+ // thanks" (嗯, 了解, いいです) are left out on purpose.
148
+ const PLAIN_APPROVAL_PHRASES = [
149
+ // English
150
+ 'yes', 'y', 'yep', 'yeah', 'yup', 'sure', 'yes go ahead', 'go ahead', 'approve', 'approved', 'i approve', 'do it', 'yes do it',
151
+ 'ok', 'okay', 'ok go ahead', 'lgtm', 'confirm', 'confirmed', 'send it', 'run it',
152
+ // Chinese (simplified and traditional)
153
+ '是', '是的', '好', '好的', '可以', '行', '同意', '批准', '确认', '確認', '没问题', '沒問題', '发吧', '發吧', '执行', '執行', '执行吧', '執行吧',
154
+ '请执行', '請執行', '好的发吧', '好的發吧', '可以执行', '可以執行', '同意执行', '同意執行',
155
+ // Japanese
156
+ 'はい', '承認', '承認します', 'お願いします', 'はいお願いします', 'okです', '進めて', '進めてください', '実行してください',
157
+ // Korean
158
+ '네', '예', '승인', '승인합니다', '좋아요', '진행해주세요', '네진행해주세요',
159
+ // Spanish, Portuguese, Italian
160
+ 'sí', 'si', 'aprobado', 'apruebo', 'adelante', 'sí adelante', 'dale', 'de acuerdo',
161
+ 'sim', 'aprovado', 'aprovo', 'pode', 'pode mandar', 'manda', 'pode seguir',
162
+ 'sì', 'approvato', 'approvo', 'vai', 'procedi', 'va bene',
163
+ // French, German
164
+ 'oui', 'approuvé', "j'approuve", 'vas-y', 'allez-y', "d'accord", 'ok vas-y',
165
+ 'ja', 'genehmigt', 'freigegeben', 'mach es', 'mach das', 'einverstanden', 'passt',
166
+ // Russian
167
+ 'да', 'одобряю', 'одобрено', 'подтверждаю', 'давай',
168
+ // Emoji, typed or as Slack shortcodes
169
+ '👍', '✅', '👌', ':+1:', ':thumbsup:', ':white_check_mark:', ':heavy_check_mark:', ':ok_hand:',
170
+ ];
171
+ // "Please" at either end of a reply doesn't change it.
172
+ const PLEASE = /^(please|pls|por favor|bitte|svp|s'il vous plaît|per favore)\s+|\s+(please|pls|por favor|bitte|svp|s'il vous plaît|per favore)$/g;
173
+ function approvalKey(text) {
174
+ return text
175
+ .toLowerCase()
176
+ .replace(/^\s*<@[a-z0-9]+>\s*/, '')
177
+ .replace(/:skin-tone-\d:/g, '')
178
+ .replace(/[\u{1F3FB}-\u{1F3FF}\u{FE0F}]/gu, '')
179
+ .replace(/[\u2018\u2019]/g, "'")
180
+ .replace(/[,,、;;]+/g, ' ')
181
+ .replace(/[.!?。!~~]+$/, (m) => (m.includes('?') ? '?' : ''))
182
+ .trim()
183
+ .replace(PLEASE, '')
184
+ .replace(/\s+/g, '');
185
+ }
186
+ const PLAIN_APPROVALS = new Set(PLAIN_APPROVAL_PHRASES.map(approvalKey));
187
+ /** Whether a reply is a plain approval: lower-cased, a leading @mention,
188
+ * emoji skin tones and "please" dropped, punctuation and spaces
189
+ * (full-width ones too) ignored, then compared with the fixed list. A
190
+ * trailing question mark keeps it from matching ("yes?"). */
191
+ export function isPlainApproval(text) {
192
+ return PLAIN_APPROVALS.has(approvalKey(text));
193
+ }
136
194
  /** Says what will run, runs it, reports the outcome and updates the
137
195
  * request message. `args` differing from the request's own means it was
138
196
  * approved as changed. */
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/not
12
12
  export { describeNotifier } from '#core/http-notifier.js';
13
13
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
14
14
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
15
- export { firstSighting, handleInboundMessage, handleMessagingAction, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
15
+ export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval, type MessagingDeps, type MessagingDecision, type DecisionOutcome } from '#core/messaging/engine.js';
16
16
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
17
17
  export { decideReply, proposeEdit, describeChange, type ReplyDecision, type DecisionRequest } from '#core/messaging/decide.js';
18
18
  export { recordThread, findThread, findThreadByPending, type ThreadLink } from '#core/messaging/threads.js';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
14
14
  export { describeNotifier } from '#core/http-notifier.js';
15
15
  export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
16
16
  export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
17
- export { firstSighting, handleInboundMessage, handleMessagingAction } from '#core/messaging/engine.js';
17
+ export { firstSighting, handleInboundMessage, handleMessagingAction, isPlainApproval } from '#core/messaging/engine.js';
18
18
  export { formatArgs, formatChange, labelFor } from '#core/messaging/format-args.js';
19
19
  export { decideReply, proposeEdit, describeChange } from '#core/messaging/decide.js';
20
20
  export { recordThread, findThread, findThreadByPending } from '#core/messaging/threads.js';
@@ -1194,7 +1194,7 @@ ${consoleShellScript}
1194
1194
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1195
1195
  }).join('');
1196
1196
  return '<section id="approversSection"><h3>Approvers</h3>' +
1197
- '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. Nothing runs on a typed reply alone: a yes shows exactly what will run with a Confirm button, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1197
+ '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count too. Anyone in the channel can still ask about a request.</p>' +
1198
1198
  (approvers.length
1199
1199
  ? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
1200
1200
  : '<p class="hint">No approvers yet. Until you add someone, anyone in the channel can use the buttons, and deciding by reply is off.</p>') +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",