@primusclaw/personal-agent 0.2.0 → 0.3.0

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/index.js CHANGED
@@ -9557,7 +9557,8 @@ You will get a short code; send \`/link CODE\` here (valid for 10 minutes, works
9557
9557
  `,
9558
9558
  tooManyWrong: (p) => `Too many wrong codes. Wait ${String(p.minutes)} minute${p.minutes !== 1 ? "s" : ""} and try again.`,
9559
9559
  linkingPaused: "Linking is paused for a few minutes because of too many wrong codes. Please try again later.",
9560
- badCode: "That code is not valid or has expired. Get a new one on the console page."
9560
+ badCode: "That code is not valid or has expired. Get a new one on the console page.",
9561
+ identityRefused: "That needs you in person: confirm your identity once on this card, then ask again."
9561
9562
  },
9562
9563
  zh: {
9563
9564
  linkSteps: (p) => `${p.lead}\u8981\u4F7F\u7528 ${p.bot}\uFF0C\u8BF7\u5148\u8FDE\u4E0A\u4F60\u7684 PrimusClaw \u8D26\u53F7\u3002\u6253\u5F00\u8FD9\u4E2A\u9875\u9762\uFF0C\u767B\u5F55\u540E\u9009\u62E9 Connect Microsoft Teams\uFF1A
@@ -9579,7 +9580,8 @@ ${p.page}
9579
9580
  `,
9580
9581
  tooManyWrong: (p) => `Too many wrong codes. Wait ${String(p.minutes)} minute${p.minutes !== 1 ? "s" : ""} and try again.`,
9581
9582
  linkingPaused: "Linking is paused for a few minutes because of too many wrong codes. Please try again later.",
9582
- badCode: "That code is not valid or has expired. Get a new one on the console page."
9583
+ badCode: "That code is not valid or has expired. Get a new one on the console page.",
9584
+ identityRefused: "\u8FD9\u4E00\u6B65\u9700\u8981\u4F60\u672C\u4EBA\uFF1A\u5728\u8FD9\u5F20\u5361\u7247\u4E0A\u786E\u8BA4\u4E00\u6B21\u8EAB\u4EFD\uFF0C\u7136\u540E\u518D\u8BF4\u4E00\u904D\u5C31\u884C\u3002"
9583
9585
  }
9584
9586
  });
9585
9587
  var LINK_KEY_NAME = "PrimusClaw Teams";
@@ -9682,6 +9684,28 @@ var Credentials = class {
9682
9684
  await chat.say(linkText.in("en").platformDown);
9683
9685
  return void 0;
9684
9686
  }
9687
+ /**
9688
+ * After an answer of the assistant that was refused for want of a confirmed identity (`needs_identity`: a person whose chats
9689
+ * run on their key because they never confirmed it asked for something only they may do, such as a review as them): the card
9690
+ * that confirms it, unless the person has confirmed by now. Nothing when chats do not run on the exchange, or it cannot be
9691
+ * asked. Never before that: connecting a machine and the rest of what a key may do are not held behind it.
9692
+ */
9693
+ async identityAfterRefusal(chat) {
9694
+ if (this.chatExchange === void 0) return false;
9695
+ const token = await this.chatExchange.tokenFor(chat.sender.id);
9696
+ if (token.ok || token.error.kind !== "not_bound") return false;
9697
+ return this.sendIdentityCard(chat, linkText.in(chat.lang()).identityRefused);
9698
+ }
9699
+ /** The binding card with `lead`; false (nothing sent) when the identity service gives no link: a key's owner is not sent the `/link` steps. */
9700
+ async sendIdentityCard(chat, lead) {
9701
+ const request = await this.deps.exchange.bindingRequest(chat.sender.id, chat.sender.email);
9702
+ if (!request.ok) {
9703
+ this.deps.logger.warn("binding request failed", { kind: request.error.kind });
9704
+ return false;
9705
+ }
9706
+ await chat.send(this.views.binding(request.value.confirmUrl, lead));
9707
+ return true;
9708
+ }
9685
9709
  /** A fresh credential per call when chats run on exchanged tokens (they expire); undefined: the key as it is. */
9686
9710
  provider(userId, key) {
9687
9711
  return this.chatExchange === void 0 ? void 0 : () => this.fresh(userId, key);
@@ -12973,16 +12997,58 @@ var PersonalRouter = class {
12973
12997
  text;
12974
12998
  stopPress;
12975
12999
  showHere;
13000
+ /** A person's bound id is read and kept one message at a time, so a late lookup of an earlier token never wins. */
13001
+ boundLock = new KeyedLock();
12976
13002
  async route(event, chat) {
12977
13003
  const { store, subscriptions } = this.ctx.deps;
12978
13004
  const userId = chat.sender.id;
12979
13005
  store.references.saveReference(userId, wireOf(event));
12980
13006
  if (event.type === "action" && event.invoke) store.members.notePersonalClick(userId);
12981
- await this.backfill(userId);
12982
- if (subscriptions !== void 0 && store.links.getKey(userId) !== null) await subscriptions.ensure(userId);
13007
+ if (store.links.getKey(userId) !== null) {
13008
+ await this.backfill(userId);
13009
+ if (subscriptions !== void 0) await subscriptions.ensure(userId);
13010
+ } else {
13011
+ await this.boundLock.run(userId, () => this.reachBound(userId));
13012
+ }
12983
13013
  if (event.type === "message") await this.message(event, chat);
12984
13014
  else await this.click(event, chat);
12985
13015
  }
13016
+ /**
13017
+ * A person with no link whose chats run on an exchanged token (bound at the identity service): their Claw user id is
13018
+ * read with the token and their subscription made with it, as a link's are, so their 1:1 chat reaches the assistant
13019
+ * from this message on (the assistant reaches only a person whose Claw user id and subscription it knows).
13020
+ *
13021
+ * The id is read again whenever the token is not the one it was read with (tokens last minutes, so at most once a token):
13022
+ * a person who rebinds to another account is followed to it, and one no longer bound is forgotten, so the assistant
13023
+ * never keeps answering for an account the person no longer holds (nor for one whose owner a new token cannot tell yet).
13024
+ * A subscription is made or mended only with a token whose owner is the id kept.
13025
+ */
13026
+ async reachBound(userId) {
13027
+ const { credentials, owners } = this.ctx;
13028
+ const { store, subscriptions } = this.ctx.deps;
13029
+ if (!credentials.onExchange) return;
13030
+ let token;
13031
+ try {
13032
+ token = await credentials.background(userId);
13033
+ } catch (error) {
13034
+ if (error instanceof CredentialUnavailable) return;
13035
+ throw error;
13036
+ }
13037
+ if (token === void 0) {
13038
+ store.links.forgetBound(userId);
13039
+ return;
13040
+ }
13041
+ const kept = store.links.boundClawUser(userId, token);
13042
+ if (kept?.current !== true) {
13043
+ const clawUserId = await owners.userIdOf(token);
13044
+ if (clawUserId === void 0) {
13045
+ if (kept !== null) store.links.forgetBound(userId);
13046
+ return;
13047
+ }
13048
+ store.links.setBoundClawUser(userId, clawUserId, token);
13049
+ }
13050
+ if (subscriptions !== void 0) await subscriptions.ensure(userId, token);
13051
+ }
12986
13052
  /** Linked before the Claw user id was kept: it is read now. */
12987
13053
  async backfill(userId) {
12988
13054
  const { links: links2 } = this.ctx.deps.store;
@@ -19398,8 +19464,27 @@ var Deliveries = class {
19398
19464
  return this.sendRemoteAsk(userId, conversation, event, remote);
19399
19465
  return void 0;
19400
19466
  }
19401
- /** What the assistant said (or a work item that needs the person and was not said). */
19467
+ /**
19468
+ * What the assistant said, then, when its turn was refused something for want of a confirmed identity (`needs_identity`) and
19469
+ * this is the person's own chat (a group binding's key has `#`), the card that confirms it. A card that could not be sent never
19470
+ * makes the message undelivered.
19471
+ */
19402
19472
  async sendSaid(userId, conversation, event) {
19473
+ const sent = await this.sendSaidWords(userId, conversation, event);
19474
+ const offer = this.deps.identityAfterRefusal;
19475
+ if (sent && offer !== void 0 && event["type"] === "assistant.say" && event["needs_identity"] === true && !userId.includes("#")) {
19476
+ try {
19477
+ await offer(userId, conversation, str5(event["turn_message_id"]) || str5(event["event_id"]));
19478
+ } catch (error) {
19479
+ this.deps.logger.warn("the identity card after a refused answer could not be sent", {
19480
+ error: error instanceof Error ? error.name : "unknown"
19481
+ });
19482
+ }
19483
+ }
19484
+ return sent;
19485
+ }
19486
+ /** What the assistant said (or a work item that needs the person and was not said). */
19487
+ async sendSaidWords(userId, conversation, event) {
19403
19488
  const card = await this.remoteCard(userId, conversation, event);
19404
19489
  if (card !== void 0) return card;
19405
19490
  let words3 = workEventText(parseWorkItem2(event["item"]) ?? null, event["text"]);
@@ -20071,13 +20156,17 @@ var Subscriptions = class {
20071
20156
  return "no_subscription";
20072
20157
  }
20073
20158
  }
20074
- /** Make sure this linked person has a subscription (with the assistant's types when it is on). Never throws. */
20075
- async ensure(aadId) {
20159
+ /**
20160
+ * Make sure this person has a subscription (with the assistant's types when it is on). It is made and mended with
20161
+ * `credential` when one is given (a person with no link, whose chats run on an exchanged token), else with their linked
20162
+ * key. Never throws.
20163
+ */
20164
+ async ensure(aadId, credential2) {
20076
20165
  try {
20166
+ const key = credential2 ?? this.store.links.getKey(aadId);
20077
20167
  let found = this.store.notifySubs.getNotifySub(aadId);
20078
- if (found !== null && this.assistantOnFor(aadId)) found = await this.reconcile(aadId, found.subscriptionId);
20168
+ if (found !== null && this.assistantOnFor(aadId)) found = await this.reconcile(aadId, found.subscriptionId, key);
20079
20169
  if (found !== null) return;
20080
- const key = this.store.links.getKey(aadId);
20081
20170
  if (key === null || !this.store.notifySubs.claimNotifyAttempt(aadId, RETRY_SECONDS)) return;
20082
20171
  await this.create(aadId, key);
20083
20172
  } catch (error) {
@@ -20117,8 +20206,7 @@ var Subscriptions = class {
20117
20206
  * at, and mended with one PATCH, at most every UPGRADE_RETRY_SECONDS. One Claw no longer has is forgotten (the caller makes a new
20118
20207
  * one); one Claw refuses even the assistant's types (400, 404) takes the person back to sessions (`types_version` 0).
20119
20208
  */
20120
- async reconcile(aadId, subscriptionId) {
20121
- const key = this.store.links.getKey(aadId);
20209
+ async reconcile(aadId, subscriptionId, key) {
20122
20210
  if (key === null || !this.store.notifySubs.claimNotifyAttempt(aadId, UPGRADE_RETRY_SECONDS)) {
20123
20211
  return this.store.notifySubs.getNotifySub(aadId);
20124
20212
  }
@@ -25,6 +25,7 @@ export declare const linkText: import("./catalog.js").Catalog<{
25
25
  }) => string;
26
26
  linkingPaused: string;
27
27
  badCode: string;
28
+ identityRefused: string;
28
29
  }>;
29
30
  /** The name the linked key has in SaFE (the console creates it under this name). */
30
31
  export declare const LINK_KEY_NAME = "PrimusClaw Teams";
@@ -30,7 +30,26 @@ export interface LinkRepository {
30
30
  setClawUserId(userId: string, clawUserId: string): void;
31
31
  /** Linked before the Claw user id was kept (or the lookup failed at link time). */
32
32
  needsClawUserId(userId: string): boolean;
33
+ /**
34
+ * The link's Claw user id; for a person with no link, the one kept for their binding at the identity service (their
35
+ * chats run on exchanged tokens). The assistant reaches either by it.
36
+ */
33
37
  clawUserIdOf(userId: string): string | null;
38
+ /**
39
+ * A person with no link, bound at the identity service: the Claw user id kept for them, and whether it was read with
40
+ * `credential` (the token they write with now); null when none is kept.
41
+ */
42
+ boundClawUser(userId: string, credential: string): {
43
+ clawUserId: string;
44
+ current: boolean;
45
+ } | null;
46
+ /**
47
+ * Keep the Claw user id read with `credential` for a bound person with no link. When it names another account than the
48
+ * one kept, their subscription (made with the earlier account's token) is forgotten, to be made again with this one.
49
+ */
50
+ setBoundClawUser(userId: string, clawUserId: string, credential: string): void;
51
+ /** A person with no link is no longer bound: forget their kept Claw user id and their subscription. */
52
+ forgetBound(userId: string): void;
34
53
  /** Every linked person, in a stable order. */
35
54
  linkedUsers(): string[];
36
55
  userForClawUser(clawUserId: string): string | null;
@@ -17,8 +17,14 @@ export declare class Deliveries {
17
17
  * question (its card edited). Undefined when the message goes on as words.
18
18
  */
19
19
  private remoteCard;
20
- /** What the assistant said (or a work item that needs the person and was not said). */
20
+ /**
21
+ * What the assistant said, then, when its turn was refused something for want of a confirmed identity (`needs_identity`) and
22
+ * this is the person's own chat (a group binding's key has `#`), the card that confirms it. A card that could not be sent never
23
+ * makes the message undelivered.
24
+ */
21
25
  private sendSaid;
26
+ /** What the assistant said (or a work item that needs the person and was not said). */
27
+ private sendSaidWords;
22
28
  /** The client locale of the message being handled ("" for a push or a sweep: Python CLIENT_LOCALE's default). */
23
29
  private clientLocale;
24
30
  /** The chat is an agent's window now: direct chat is on for the person and the chat is attached to a session. */
@@ -74,4 +74,10 @@ export interface ProactiveDeps {
74
74
  * handled (`/resume`, quiet hours off) answer in their client's language; a push or a sweep reads none. Default: none.
75
75
  */
76
76
  readonly requestScope?: RequestScope;
77
+ /**
78
+ * After an assistant message whose turn was refused for want of a confirmed identity (`needs_identity`), in the person's own
79
+ * chat: the card that confirms it (`Credentials.identityAfterRefusal`), once per turn (`turn`: the message the turn answers,
80
+ * else the say's own id; a later say of the same turn replaces the first and carries the field too). Default: none.
81
+ */
82
+ readonly identityAfterRefusal?: (userId: string, conversation: Conversation, turn: string) => Promise<void>;
77
83
  }
@@ -171,6 +171,11 @@ export interface AssistantSayPayload extends AssistantEventBase {
171
171
  replaces?: string;
172
172
  /** The message id of the turn a system notice answers or a router say belongs to (what an `amends` or a `replaces` is matched by). */
173
173
  turn_message_id?: string;
174
+ /**
175
+ * Present when the turn was refused something only the person may do because their message came with a key, not with a confirmed
176
+ * identity (`not_attended`): a channel that can bind the person's identity offers it with this message; one that cannot ignores it.
177
+ */
178
+ needs_identity?: true;
174
179
  }
175
180
  /** The system's own message, sent when an item has needed the user for a while and nobody has said so. */
176
181
  export interface WorkItemNeedsYouPayload extends AssistantEventBase {
@@ -25,6 +25,15 @@ export declare class Credentials {
25
25
  get onExchange(): boolean;
26
26
  /** What to call Claw with for this person, or undefined after they were told what to do (`ask`). */
27
27
  credential(chat: Chat, ask?: boolean): Promise<string | undefined>;
28
+ /**
29
+ * After an answer of the assistant that was refused for want of a confirmed identity (`needs_identity`: a person whose chats
30
+ * run on their key because they never confirmed it asked for something only they may do, such as a review as them): the card
31
+ * that confirms it, unless the person has confirmed by now. Nothing when chats do not run on the exchange, or it cannot be
32
+ * asked. Never before that: connecting a machine and the rest of what a key may do are not held behind it.
33
+ */
34
+ identityAfterRefusal(chat: Chat): Promise<boolean>;
35
+ /** The binding card with `lead`; false (nothing sent) when the identity service gives no link: a key's owner is not sent the `/link` steps. */
36
+ private sendIdentityCard;
28
37
  /** A fresh credential per call when chats run on exchanged tokens (they expire); undefined: the key as it is. */
29
38
  provider(userId: string, key: string): CredentialProvider;
30
39
  /** The person's exchanged token, else `fallback` (a turn can outlive a 10-minute token: each Claw call asks again). */
@@ -134,7 +134,8 @@ export interface RemoteChat {
134
134
  }
135
135
  /** The person's notification subscription in Claw (Python `subscriptions.Subscriptions`); undefined when notifications are off. */
136
136
  export interface Subscriptions {
137
- ensure(userId: string): Promise<void>;
137
+ /** Made or mended with `credential` when given (a person with no link: their exchanged token), else the linked key. */
138
+ ensure(userId: string, credential?: string): Promise<void>;
138
139
  /** Before the key is revoked: Claw needs it to delete the subscription. */
139
140
  remove(userId: string, key: string): Promise<void>;
140
141
  }
@@ -15,8 +15,21 @@ export declare class PersonalRouter {
15
15
  private readonly text;
16
16
  private readonly stopPress;
17
17
  private readonly showHere;
18
+ /** A person's bound id is read and kept one message at a time, so a late lookup of an earlier token never wins. */
19
+ private readonly boundLock;
18
20
  constructor(ctx: RouterContext, sets: CommandSets, text: PersonalText);
19
21
  route(event: InboundMessage | InboundAction, chat: Chat): Promise<void>;
22
+ /**
23
+ * A person with no link whose chats run on an exchanged token (bound at the identity service): their Claw user id is
24
+ * read with the token and their subscription made with it, as a link's are, so their 1:1 chat reaches the assistant
25
+ * from this message on (the assistant reaches only a person whose Claw user id and subscription it knows).
26
+ *
27
+ * The id is read again whenever the token is not the one it was read with (tokens last minutes, so at most once a token):
28
+ * a person who rebinds to another account is followed to it, and one no longer bound is forgotten, so the assistant
29
+ * never keeps answering for an account the person no longer holds (nor for one whose owner a new token cannot tell yet).
30
+ * A subscription is made or mended only with a token whose owner is the id kept.
31
+ */
32
+ private reachBound;
20
33
  /** Linked before the Claw user id was kept: it is read now. */
21
34
  private backfill;
22
35
  private message;
@@ -51,8 +51,12 @@ export declare class Subscriptions {
51
51
  * mended first and the user id filled in, so what is said is the state after trying. Never throws.
52
52
  */
53
53
  reachable(aadId: string): Promise<Unreachable>;
54
- /** Make sure this linked person has a subscription (with the assistant's types when it is on). Never throws. */
55
- ensure(aadId: string): Promise<void>;
54
+ /**
55
+ * Make sure this person has a subscription (with the assistant's types when it is on). It is made and mended with
56
+ * `credential` when one is given (a person with no link, whose chats run on an exchanged token), else with their linked
57
+ * key. Never throws.
58
+ */
59
+ ensure(aadId: string, credential?: string): Promise<void>;
56
60
  private create;
57
61
  /**
58
62
  * With the assistant on, the recorded subscription must be there in Claw, enabled and asking for the assistant's events: looked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primusclaw/personal-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The personal agent's channel-neutral core: domain logic, ports and plugin interfaces.",
5
5
  "license": "MIT",
6
6
  "repository": {