@inline-chat/hermes-agent-adapter 0.0.6 → 0.0.8-alpha.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/README.md CHANGED
@@ -81,7 +81,7 @@ Set up the Inline Hermes Agent adapter on this machine.
81
81
  Constraints:
82
82
  - Do not read, print, or edit .env files.
83
83
  - Do not print Inline tokens or other secrets.
84
- - Use an Inline token from INLINE_TOKEN or INLINE_BOT_TOKEN; if neither is present, stop and point me to https://inline.chat/docs/creating-a-bot.
84
+ - Use an Inline token from INLINE_TOKEN or INLINE_BOT_TOKEN when already available; otherwise use Hermes's guided setup without exposing the token.
85
85
 
86
86
  Tasks:
87
87
  1. Verify Node.js is version 20 or newer and Hermes Agent is installed.
@@ -110,6 +110,23 @@ hermes inline status
110
110
  inline-hermes --version
111
111
  ```
112
112
 
113
+ The Inline CLI can drive Hermes setup without putting the bot token in argv:
114
+
115
+ ```sh
116
+ inline agents setup --target hermes --non-interactive --json
117
+ ```
118
+
119
+ For other orchestrators, the plugin also exposes a token-free machine contract.
120
+ The token is accepted only on stdin and is saved through Hermes's credential
121
+ helper:
122
+
123
+ ```sh
124
+ printf '%s\n' "$INLINE_BOT_TOKEN" | hermes inline setup \
125
+ --non-interactive --token-stdin --owner-user-id 123 \
126
+ --access owner --json
127
+ hermes inline status --json --probe
128
+ ```
129
+
113
130
  `doctor` verifies required plugin files, the Node executable used for the
114
131
  sidecar, the installed sidecar bundle hash, and whether Hermes config enables
115
132
  the external plugin with `plugins.enabled: [inline-platform]`. It detects
@@ -129,7 +146,7 @@ uv run ./hermes plugins list --plain --no-bundled
129
146
  Expected local output includes:
130
147
 
131
148
  ```text
132
- enabled user 0.0.6 inline-platform
149
+ enabled user 0.0.8-alpha.0 inline-platform
133
150
  ```
134
151
 
135
152
  ## Update Or Reinstall
@@ -152,7 +169,7 @@ mismatch, rerun the same command after rebuilding or upgrading the package.
152
169
 
153
170
  - Hermes Agent: requires the external user plugin registry and native platform
154
171
  plugin loader available in Hermes Agent `0.17.x`. This package was validated
155
- against Hermes Agent `0.19.1` from source commit `cc4cab2`.
172
+ against Hermes Agent `0.20.0` from source commit `3c27eb6`.
156
173
  - Node.js: `>=20` is required for the bundled sidecar. Hermes-managed Node 22,
157
174
  system Node, or an explicit `INLINE_NODE_BIN` path all work.
158
175
  - Inline transport: the sidecar uses `@inline-chat/realtime-sdk@0.0.14` and is
@@ -174,10 +191,10 @@ Before publishing, run:
174
191
  bun run release:preflight
175
192
  ```
176
193
 
177
- This runs `npm publish --dry-run --access public`. npm invokes
178
- `prepublishOnly`, so the dry-run must pass `bun run check`, rebuild the packed
179
- runtime files, verify the shipped `inline-hermes` binary, and print the final
180
- tarball contents without publishing.
194
+ This creates an isolated registry-dependency stage, runs the full package
195
+ check, packs one read-only tarball, and runs `npm publish --dry-run` against
196
+ those exact bytes. It prints the artifact path, SHA-256, and file list so the
197
+ trusted-publishing workflow can hash-check and publish the same artifact.
181
198
 
182
199
  Maintainers should also run the manual live-test and publish checklist in
183
200
  [`hermes-agent/RELEASE.md`](https://github.com/inline-chat/inline/blob/main/hermes-agent/RELEASE.md).
package/dist/install.js CHANGED
@@ -7047,7 +7047,7 @@ var minNodeMajor = 20;
7047
7047
  async function main(argv = process.argv.slice(2)) {
7048
7048
  const opts = parseArgs(argv);
7049
7049
  if (opts.command === "help") {
7050
- printHelp();
7050
+ printHelp(opts.helpTopic);
7051
7051
  return 0;
7052
7052
  }
7053
7053
  const pkg = await readPackageInfo();
@@ -7064,7 +7064,7 @@ async function main(argv = process.argv.slice(2)) {
7064
7064
  } else {
7065
7065
  printStatus(status);
7066
7066
  }
7067
- return status.ok ? 0 : opts.command === "doctor" ? 1 : 0;
7067
+ return status.ok ? 0 : 1;
7068
7068
  }
7069
7069
  if (opts.command === "test-send") {
7070
7070
  const result2 = await testSend({ source, opts, pkg });
@@ -7087,8 +7087,15 @@ async function installPlugin(params) {
7087
7087
  const before = await inspectInstall(params);
7088
7088
  if (!before.sourceValid)
7089
7089
  return { ...before, action: "install", installed: false };
7090
- if (params.opts.dryRun)
7091
- return { ...before, action: "install", installed: false };
7090
+ if (params.opts.dryRun) {
7091
+ return {
7092
+ ...before,
7093
+ ok: before.sourceReady,
7094
+ action: "install",
7095
+ installed: false,
7096
+ issues: before.sourceReady ? [] : before.issues
7097
+ };
7098
+ }
7092
7099
  await mkdir(path.dirname(params.target), { recursive: true });
7093
7100
  if (before.targetExists) {
7094
7101
  if (!params.opts.force) {
@@ -7119,7 +7126,7 @@ async function installPlugin(params) {
7119
7126
  return {
7120
7127
  ...after,
7121
7128
  action: "install",
7122
- installed: after.targetValid
7129
+ installed: after.installedReady
7123
7130
  };
7124
7131
  }
7125
7132
  function shouldCopyPluginFile(src) {
@@ -7143,13 +7150,16 @@ async function inspectInstall(params) {
7143
7150
  const activation = await inspectActivation(params.opts.hermesHome, params.target);
7144
7151
  const issues = [];
7145
7152
  const warnings = [];
7153
+ const sidecarMatches = Boolean(targetSidecar?.exists && sourceSidecar.sha256 && targetSidecar.sha256 && sourceSidecar.sha256 === targetSidecar.sha256);
7154
+ const sourceReady = sourceValid && sourceSidecar.exists && node.ok;
7155
+ const installedReady = targetExists && targetValid && sidecarMatches && node.ok;
7146
7156
  if (!sourceValid) {
7147
7157
  issues.push(`plugin source is incomplete: ${params.source}`);
7148
7158
  }
7149
7159
  if (targetExists && !targetValid) {
7150
7160
  issues.push(`installed plugin is incomplete: ${params.target}`);
7151
7161
  }
7152
- if (params.opts.command === "doctor" && !targetExists) {
7162
+ if ((params.opts.command === "status" || params.opts.command === "doctor") && !targetExists) {
7153
7163
  issues.push(`plugin is not installed: ${params.target}`);
7154
7164
  }
7155
7165
  if (targetSidecar?.exists && sourceSidecar.sha256 && targetSidecar.sha256 && sourceSidecar.sha256 !== targetSidecar.sha256) {
@@ -7171,7 +7181,7 @@ async function inspectInstall(params) {
7171
7181
  warnings.push("Installed plugin ownership differs from the Hermes home owner; internal /inline_update may fail until inline-hermes install --force repairs it");
7172
7182
  }
7173
7183
  return {
7174
- ok: sourceValid && (!targetExists || targetValid) && node.ok && issues.length === 0,
7184
+ ok: installedReady && issues.length === 0,
7175
7185
  action: "status",
7176
7186
  packageName: params.pkg.name,
7177
7187
  packageVersion: params.pkg.version,
@@ -7180,14 +7190,17 @@ async function inspectInstall(params) {
7180
7190
  source: params.source,
7181
7191
  sourceReal,
7182
7192
  sourceValid,
7193
+ sourceReady,
7183
7194
  target: params.target,
7184
7195
  targetReal,
7185
7196
  targetExists,
7186
7197
  targetValid,
7198
+ installedReady,
7187
7199
  targetLinked,
7188
7200
  sidecar: {
7189
7201
  source: sourceSidecar,
7190
- target: targetSidecar
7202
+ target: targetSidecar,
7203
+ matchesPackage: sidecarMatches
7191
7204
  },
7192
7205
  node,
7193
7206
  activation,
@@ -7298,21 +7311,40 @@ async function testSend(params) {
7298
7311
  }
7299
7312
  }
7300
7313
  function parseArgs(argv) {
7301
- const command = parseCommand(argv[0]);
7302
- const opts = {
7303
- command,
7304
- hermesHome: process.env.HERMES_HOME || path.join(os.homedir(), ".hermes"),
7305
- pluginId: defaultPluginId,
7306
- link: false,
7307
- dryRun: false,
7308
- force: false,
7309
- json: false,
7310
- text: "Inline Hermes test",
7311
- timeoutMs: 30000
7312
- };
7314
+ if (argv[0] === "help") {
7315
+ const helpTopic = argv[1] == null ? undefined : parseRuntimeCommand(argv[1]);
7316
+ if (argv.length > (helpTopic ? 2 : 1))
7317
+ throw new Error(`unknown argument: ${argv[helpTopic ? 2 : 1]}`);
7318
+ return defaultOptions("help", helpTopic);
7319
+ }
7320
+ if (argv[0] === "version") {
7321
+ if (argv.length !== 1)
7322
+ throw new Error(`unknown argument: ${argv[1]}`);
7323
+ return defaultOptions("version");
7324
+ }
7313
7325
  const firstArgIsFlag = argv[0]?.startsWith("-") ?? false;
7314
- for (let i = argv[0] == null || firstArgIsFlag ? 0 : 1;i < argv.length; i += 1) {
7326
+ const selectedCommand = firstArgIsFlag || argv[0] == null ? "install" : parseRuntimeCommand(argv[0]);
7327
+ const opts = defaultOptions(selectedCommand);
7328
+ const startIndex = argv[0] == null || firstArgIsFlag ? 0 : 1;
7329
+ if (argv[startIndex] === "-v" || argv[startIndex] === "--version") {
7330
+ if (startIndex !== 0 || argv.length !== 1)
7331
+ throw new Error("--version is only valid as a top-level flag");
7332
+ opts.command = "version";
7333
+ return opts;
7334
+ }
7335
+ for (let i = startIndex;i < argv.length; i += 1) {
7315
7336
  const arg = argv[i];
7337
+ if (arg == null)
7338
+ throw new Error("missing argument");
7339
+ if (arg === "-h" || arg === "--help") {
7340
+ if (argv.length !== i + 1)
7341
+ throw new Error(`${arg} must be the final argument`);
7342
+ opts.command = "help";
7343
+ if (startIndex !== 0)
7344
+ opts.helpTopic = selectedCommand;
7345
+ return opts;
7346
+ }
7347
+ assertOptionAllowed(selectedCommand, arg);
7316
7348
  switch (arg) {
7317
7349
  case "--hermes-home":
7318
7350
  opts.hermesHome = requireValue(argv, ++i, arg);
@@ -7347,14 +7379,6 @@ function parseArgs(argv) {
7347
7379
  case "--timeout-ms":
7348
7380
  opts.timeoutMs = parsePositiveInt(requireValue(argv, ++i, arg), arg);
7349
7381
  break;
7350
- case "-h":
7351
- case "--help":
7352
- opts.command = "help";
7353
- break;
7354
- case "-v":
7355
- case "--version":
7356
- opts.command = "version";
7357
- break;
7358
7382
  default:
7359
7383
  throw new Error(`unknown argument: ${arg}`);
7360
7384
  }
@@ -7363,17 +7387,39 @@ function parseArgs(argv) {
7363
7387
  validatePluginId(opts.pluginId);
7364
7388
  return opts;
7365
7389
  }
7366
- function parseCommand(raw) {
7367
- if (raw == null || raw === "install")
7368
- return "install";
7369
- if (raw === "status" || raw === "doctor" || raw === "test-send" || raw === "help" || raw === "version")
7390
+ function defaultOptions(command, helpTopic) {
7391
+ const opts = {
7392
+ command,
7393
+ hermesHome: process.env.HERMES_HOME || path.join(os.homedir(), ".hermes"),
7394
+ pluginId: defaultPluginId,
7395
+ link: false,
7396
+ dryRun: false,
7397
+ force: false,
7398
+ json: false,
7399
+ text: "Inline Hermes test",
7400
+ timeoutMs: 30000,
7401
+ ...helpTopic ? { helpTopic } : {}
7402
+ };
7403
+ return opts;
7404
+ }
7405
+ function parseRuntimeCommand(raw) {
7406
+ if (raw === "install" || raw === "status" || raw === "doctor" || raw === "test-send")
7370
7407
  return raw;
7371
- if (raw === "-h" || raw === "--help")
7372
- return "help";
7373
- if (raw === "-v" || raw === "--version")
7374
- return "version";
7375
7408
  throw new Error(`unknown command: ${raw}`);
7376
7409
  }
7410
+ var commandOptions = {
7411
+ install: new Set(["--hermes-home", "--plugin-id", "--link", "--dry-run", "--force", "--json"]),
7412
+ status: new Set(["--hermes-home", "--plugin-id", "--json"]),
7413
+ doctor: new Set(["--hermes-home", "--plugin-id", "--json"]),
7414
+ "test-send": new Set(["--hermes-home", "--dry-run", "--json", "--to", "--text", "--base-url", "--state-path", "--timeout-ms"])
7415
+ };
7416
+ function assertOptionAllowed(command, option) {
7417
+ if (!option.startsWith("-"))
7418
+ throw new Error(`unexpected positional argument for ${command}: ${option}`);
7419
+ if (!commandOptions[command].has(option)) {
7420
+ throw new Error(`${option} is not valid for inline-hermes ${command}`);
7421
+ }
7422
+ }
7377
7423
  function requireValue(argv, index, flag) {
7378
7424
  const value = argv[index];
7379
7425
  if (value == null || value.startsWith("--")) {
@@ -7525,20 +7571,20 @@ async function inspectInstallOwnership(hermesHome, target) {
7525
7571
  try {
7526
7572
  const homeInfo = await stat(hermesHome);
7527
7573
  const pluginsInfo = await stat(path.dirname(target));
7528
- return pluginsInfo.uid === homeInfo.uid && pluginsInfo.gid === homeInfo.gid && await treeOwnershipMatches(target, homeInfo.uid, homeInfo.gid);
7574
+ return pluginsInfo.uid === homeInfo.uid && await treeOwnershipMatches(target, homeInfo.uid);
7529
7575
  } catch {
7530
7576
  return false;
7531
7577
  }
7532
7578
  }
7533
- async function treeOwnershipMatches(target, uid, gid) {
7579
+ async function treeOwnershipMatches(target, uid) {
7534
7580
  const info = await lstat(target);
7535
- if (info.uid !== uid || info.gid !== gid)
7581
+ if (info.uid !== uid)
7536
7582
  return false;
7537
7583
  if (!info.isDirectory())
7538
7584
  return true;
7539
7585
  const entries = await readdir(target);
7540
7586
  for (const entry of entries) {
7541
- if (!await treeOwnershipMatches(path.join(target, entry), uid, gid))
7587
+ if (!await treeOwnershipMatches(path.join(target, entry), uid))
7542
7588
  return false;
7543
7589
  }
7544
7590
  return true;
@@ -8001,28 +8047,68 @@ function formatSidecarInfo(info) {
8001
8047
  const digest = info.sha256 ? info.sha256.slice(0, 12) : "unknown";
8002
8048
  return `${info.path} (${info.size ?? 0} bytes, sha256 ${digest})`;
8003
8049
  }
8004
- function printHelp() {
8050
+ function printHelp(topic) {
8051
+ if (topic === "install") {
8052
+ console.log(`Usage: inline-hermes install [options]
8053
+
8054
+ Install the packaged Inline plugin into a Hermes home.
8055
+
8056
+ Options:
8057
+ --hermes-home <path> Hermes home directory. Defaults to HERMES_HOME or ~/.hermes.
8058
+ --plugin-id <id> Installed plugin directory name. Defaults to inline.
8059
+ --link Symlink the packaged plugin for local development.
8060
+ --force Replace an existing target plugin directory.
8061
+ --dry-run Validate packaged source and Node without writing files.
8062
+ --json Print machine-readable output.
8063
+ -h, --help Show this command help.
8064
+ `);
8065
+ return;
8066
+ }
8067
+ if (topic === "status" || topic === "doctor") {
8068
+ console.log(`Usage: inline-hermes ${topic} [options]
8069
+
8070
+ ${topic === "doctor" ? "Diagnose" : "Inspect"} the installed Inline plugin, sidecar integrity, and Node runtime.
8071
+
8072
+ Options:
8073
+ --hermes-home <path> Hermes home directory. Defaults to HERMES_HOME or ~/.hermes.
8074
+ --plugin-id <id> Installed plugin directory name. Defaults to inline.
8075
+ --json Print machine-readable output.
8076
+ -h, --help Show this command help.
8077
+ `);
8078
+ return;
8079
+ }
8080
+ if (topic === "test-send") {
8081
+ console.log(`Usage: inline-hermes test-send --to chat:<id>|user:<id> [options]
8082
+
8083
+ Probe the packaged sidecar with a bounded Inline send, or validate it with --dry-run.
8084
+
8085
+ Options:
8086
+ --hermes-home <path> Hermes home used for token/config resolution.
8087
+ --to <target> Inline destination, for example chat:123 or user:42.
8088
+ --text <message> Message text. Defaults to "Inline Hermes test".
8089
+ --base-url <url> Inline API base URL. Defaults to https://api.inline.chat.
8090
+ --state-path <path> Inline SDK state file used by the probe sidecar.
8091
+ --timeout-ms <ms> Sidecar readiness timeout. Defaults to 30000.
8092
+ --dry-run Validate inputs without starting the sidecar or sending.
8093
+ --json Print machine-readable output.
8094
+ -h, --help Show this command help.
8095
+ `);
8096
+ return;
8097
+ }
8005
8098
  console.log(`inline-hermes
8006
8099
 
8007
8100
  Usage:
8008
- inline-hermes install [--hermes-home <path>] [--link] [--force] [--dry-run]
8101
+ inline-hermes install [options]
8009
8102
  inline-hermes status [--hermes-home <path>] [--json]
8010
8103
  inline-hermes doctor [--hermes-home <path>] [--json]
8011
- inline-hermes test-send --to chat:<id>|user:<id> [--text <message>] [--json]
8104
+ inline-hermes test-send --to chat:<id>|user:<id> [options]
8105
+ inline-hermes help [install|status|doctor|test-send]
8012
8106
  inline-hermes version
8013
8107
 
8014
- Options:
8015
- --hermes-home <path> Hermes home directory. Defaults to HERMES_HOME or ~/.hermes.
8016
- --link Symlink plugin source instead of copying it.
8017
- --force Replace an existing target plugin directory.
8018
- --dry-run Print what would happen without writing files.
8019
- --json Print machine-readable output.
8020
- --to <target> Inline target for test-send, for example chat:123.
8021
- --text <message> Message for test-send. Defaults to "Inline Hermes test".
8022
- --base-url <url> Inline API base URL for test-send.
8023
- --state-path <path> Inline SDK state file for test-send.
8024
- --timeout-ms <ms> test-send sidecar readiness timeout. Defaults to 30000.
8025
- -v, --version Print package version.
8108
+ Run \`inline-hermes <command> --help\` for command-scoped options.
8109
+ Top-level options:
8110
+ -h, --help Show this help.
8111
+ -v, --version Print package version.
8026
8112
 
8027
8113
  After install, enable the external Hermes plugin with:
8028
8114
  hermes plugins enable ${pluginEnableKey}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inline-chat/hermes-agent-adapter",
3
- "version": "0.0.6",
3
+ "version": "0.0.8-alpha.0",
4
4
  "description": "Hermes Agent platform adapter for Inline, with a native Python plugin and bundled Inline realtime sidecar.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -60,7 +60,7 @@
60
60
  "check": "bun run typecheck && bun run lint && bun run test"
61
61
  },
62
62
  "dependencies": {
63
- "@inline-chat/realtime-sdk": "0.0.14",
63
+ "@inline-chat/realtime-sdk": "0.0.15-alpha.0",
64
64
  "yaml": "2.9.0"
65
65
  },
66
66
  "devDependencies": {
@@ -75,8 +75,12 @@
75
75
  "inlineHermes": {
76
76
  "pluginId": "inline",
77
77
  "pluginPath": "plugin/inline",
78
+ "install": {
79
+ "npmSpec": "@inline-chat/hermes-agent-adapter"
80
+ },
81
+ "machineSetupProtocol": 1,
78
82
  "minHermesVersion": "0.17.0",
79
- "testedHermesVersion": "0.19.1",
80
- "testedHermesCommit": "cc4cab2"
83
+ "testedHermesVersion": "0.20.0",
84
+ "testedHermesCommit": "3c27eb6"
81
85
  }
82
86
  }
@@ -69,6 +69,8 @@ _DEFAULT_OBSERVED_CONTEXT_LIMIT = 20
69
69
  _MAX_OBSERVED_CONTEXT_LIMIT = 100
70
70
  _MAX_CONTEXT_HISTORY_LIMIT = 20
71
71
  _MAX_CONTEXT_REQUEST_LIMIT = 100
72
+ _JOIN_MENTION_LOOKBACK_SECONDS = 60
73
+ _JOIN_HISTORY_PAGE_LIMIT = 100
72
74
  _CONTEXT_MESSAGE_TEXT_LIMIT = 360
73
75
  _OBSERVED_CONTEXT_CACHE_MAX_SIZE = 512
74
76
  _STATE_DIR = Path.home() / ".hermes" / "inline"
@@ -855,6 +857,7 @@ class InlineAdapter(BasePlatformAdapter):
855
857
  self._me_id: Optional[str] = None
856
858
  self._me_username: Optional[str] = None
857
859
  self._seen_messages: Dict[str, float] = {}
860
+ self._seen_message_instances: Dict[str, float] = {}
858
861
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
859
862
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
860
863
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
@@ -2048,7 +2051,12 @@ class InlineAdapter(BasePlatformAdapter):
2048
2051
  if self._system_events:
2049
2052
  await self._dispatch_message(event, edit=True)
2050
2053
  return
2051
- if kind in {"message.delete", "message.history.clear", "chat.participant.add", "chat.participant.delete"}:
2054
+ if kind == "chat.participant.add":
2055
+ if await self._recover_self_join_mentions(event):
2056
+ return
2057
+ await self._dispatch_system_event(event)
2058
+ return
2059
+ if kind in {"message.delete", "message.history.clear", "chat.participant.delete"}:
2052
2060
  await self._dispatch_system_event(event)
2053
2061
  return
2054
2062
  if kind != "message.new":
@@ -2068,6 +2076,28 @@ class InlineAdapter(BasePlatformAdapter):
2068
2076
  del self._seen_messages[stale]
2069
2077
  return False
2070
2078
 
2079
+ def _is_duplicate_message_instance(self, chat_id: str, msg: Dict[str, Any], event: Dict[str, Any]) -> bool:
2080
+ message_date = str(msg.get("date") or event.get("date") or "").strip()
2081
+ content = json.dumps({
2082
+ "fromId": msg.get("fromId"),
2083
+ "message": msg.get("message"),
2084
+ "entities": msg.get("entities"),
2085
+ "media": msg.get("media"),
2086
+ }, sort_keys=True, default=str, separators=(",", ":"))
2087
+ fingerprint = hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
2088
+ key = f"{chat_id}:{str(msg.get('id') or '')}:{message_date}:{fingerprint}"
2089
+ now = time.time()
2090
+ old = self._seen_message_instances.get(key)
2091
+ if old is not None and now - old < _DEDUP_WINDOW_SECONDS:
2092
+ return True
2093
+ if key in self._seen_message_instances:
2094
+ del self._seen_message_instances[key]
2095
+ self._seen_message_instances[key] = now
2096
+ if len(self._seen_message_instances) > _DEDUP_MAX_SIZE:
2097
+ for stale in list(self._seen_message_instances.keys())[: len(self._seen_message_instances) - _DEDUP_MAX_SIZE]:
2098
+ del self._seen_message_instances[stale]
2099
+ return False
2100
+
2071
2101
  async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) -> None:
2072
2102
  msg = event.get("message") or {}
2073
2103
  msg_id = str(msg.get("id") or "")
@@ -2089,6 +2119,8 @@ class InlineAdapter(BasePlatformAdapter):
2089
2119
  dedup_key = f"new:{chat_id}:msg:{msg_id}"
2090
2120
  if self._is_duplicate(dedup_key):
2091
2121
  return
2122
+ if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2123
+ return
2092
2124
  from_id = str(msg.get("fromId") or "")
2093
2125
  if msg.get("out") or (self._me_id and from_id == self._me_id):
2094
2126
  return
@@ -2209,6 +2241,21 @@ class InlineAdapter(BasePlatformAdapter):
2209
2241
  parent_message_id = msg_id
2210
2242
 
2211
2243
  channel_prompt, auto_skill = self._resolve_thread_bindings(chat_id, thread_id, parent_chat_id)
2244
+ mentioned_agent_id = self._mentioned_agent_id(msg)
2245
+ if mentioned_agent_id:
2246
+ try:
2247
+ resolved_agent = await self._sidecar_call("/get-agent", {"agentId": mentioned_agent_id})
2248
+ agent = resolved_agent.get("agent") if isinstance(resolved_agent.get("agent"), dict) else {}
2249
+ agent_name = str(agent.get("name") or "").strip()
2250
+ agent_instructions = str(agent.get("instructions") or "").strip()
2251
+ agent_skill = str(agent.get("skillKey") or agent.get("skill_key") or "").strip()
2252
+ if agent_name:
2253
+ specialization = agent_instructions or f'You are a specialized agent named "{agent_name}".'
2254
+ channel_prompt = self._merge_channel_prompt(channel_prompt, specialization)
2255
+ if agent_skill:
2256
+ auto_skill = [agent_skill]
2257
+ except Exception as exc:
2258
+ logger.warning("[inline] failed to resolve mentioned Agent %s: %s", mentioned_agent_id, exc)
2212
2259
  entity_text = self._inline_entity_text(msg, str(msg.get("message") or ""))
2213
2260
  parent_chat_info: Dict[str, Any] = {}
2214
2261
  if parent_chat_id:
@@ -2298,6 +2345,92 @@ class InlineAdapter(BasePlatformAdapter):
2298
2345
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2299
2346
  ))
2300
2347
 
2348
+ def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2349
+ if not self._me_id:
2350
+ return False
2351
+ if bool(msg.get("mentioned")):
2352
+ return True
2353
+ for entity in self._message_entities(msg):
2354
+ if self._entity_kind(entity) != "mention":
2355
+ continue
2356
+ payload = self._entity_payload(entity, "mention")
2357
+ if self._entity_id(payload, "userId") == self._me_id:
2358
+ return True
2359
+ username = str(self._me_username or "").strip().lstrip("@")
2360
+ text = str(msg.get("message") or "")
2361
+ if not username or not text:
2362
+ return False
2363
+ return bool(re.search(rf"(^|\s)@{re.escape(username)}(?=$|[\s,.:;!?])", text, re.IGNORECASE))
2364
+
2365
+ async def _recover_self_join_mentions(self, event: Dict[str, Any]) -> bool:
2366
+ participant = event.get("participant") if isinstance(event.get("participant"), dict) else {}
2367
+ if not self._me_id or str(participant.get("userId") or "").strip() != self._me_id:
2368
+ return False
2369
+
2370
+ chat_id = str(event.get("chatId") or "").strip()
2371
+ joined_at = _to_int(participant.get("date") or event.get("date"))
2372
+ if not chat_id or joined_at is None or joined_at <= 0:
2373
+ logger.warning("[inline] cannot recover join mentions without chat and participant date")
2374
+ return True
2375
+
2376
+ cutoff = joined_at - _JOIN_MENTION_LOOKBACK_SECONDS
2377
+ messages: Dict[str, Dict[str, Any]] = {}
2378
+ anchor_id: Optional[str] = None
2379
+ while True:
2380
+ body: Dict[str, Any] = {
2381
+ "target": _target_from_chat_id(chat_id),
2382
+ "limit": _JOIN_HISTORY_PAGE_LIMIT,
2383
+ }
2384
+ if anchor_id:
2385
+ body["anchorId"] = anchor_id
2386
+ try:
2387
+ data = await self._sidecar_call("/history", body)
2388
+ except Exception as exc:
2389
+ logger.warning("[inline] join mention history unavailable for chat %s: %s", chat_id, exc)
2390
+ return True
2391
+ page = (data.get("result") or {}).get("messages") or []
2392
+ page = [message for message in page if isinstance(message, dict)]
2393
+ if not page:
2394
+ break
2395
+
2396
+ oldest: Optional[Dict[str, Any]] = None
2397
+ added = 0
2398
+ for message in page:
2399
+ message_id = str(message.get("id") or "").strip()
2400
+ message_date = _to_int(message.get("date"))
2401
+ if not message_id or message_date is None:
2402
+ continue
2403
+ key = f"{message_id}:{message_date}"
2404
+ if key not in messages:
2405
+ messages[key] = message
2406
+ added += 1
2407
+ if oldest is None or message_date < int(oldest["date"]):
2408
+ oldest = {**message, "date": message_date}
2409
+ if oldest is None or int(oldest["date"]) < cutoff or len(page) < _JOIN_HISTORY_PAGE_LIMIT or added == 0:
2410
+ break
2411
+ anchor_id = str(oldest.get("id") or "").strip() or None
2412
+ if not anchor_id:
2413
+ break
2414
+
2415
+ candidates = []
2416
+ for message in messages.values():
2417
+ message_date = _to_int(message.get("date"))
2418
+ if message_date is None or message_date < cutoff or message_date > joined_at:
2419
+ continue
2420
+ if str(message.get("fromId") or "") == self._me_id:
2421
+ continue
2422
+ if self._message_explicitly_mentions_me(message):
2423
+ candidates.append(message)
2424
+ candidates.sort(key=lambda message: (_to_int(message.get("date")) or 0, str(message.get("id") or "")))
2425
+
2426
+ for message in candidates:
2427
+ await self._dispatch_message({
2428
+ "kind": "message.new",
2429
+ "chatId": chat_id,
2430
+ "message": {**message, "mentioned": True},
2431
+ })
2432
+ return True
2433
+
2301
2434
  async def _dispatch_reaction(self, event: Dict[str, Any], *, added: bool) -> None:
2302
2435
  reaction = event.get("reaction") if isinstance(event.get("reaction"), dict) else {}
2303
2436
  chat_id = str(event.get("chatId") or reaction.get("chatId") or "")
@@ -2770,6 +2903,19 @@ class InlineAdapter(BasePlatformAdapter):
2770
2903
  parts.append(f"+{len(entities) - _INLINE_ENTITY_LIMIT} more")
2771
2904
  return " | ".join(parts) if parts else None
2772
2905
 
2906
+ def _mentioned_agent_id(self, msg: Dict[str, Any]) -> Optional[str]:
2907
+ for entity in self._message_entities(msg):
2908
+ if self._entity_kind(entity) != "mention":
2909
+ continue
2910
+ payload = self._entity_payload(entity, "mention")
2911
+ user_id = self._entity_id(payload, "userId")
2912
+ if self._me_id and user_id != self._me_id:
2913
+ continue
2914
+ agent_id = self._entity_id(payload, "agentId")
2915
+ if agent_id:
2916
+ return agent_id
2917
+ return None
2918
+
2773
2919
  @staticmethod
2774
2920
  def _merge_channel_prompt(*parts: Optional[str]) -> Optional[str]:
2775
2921
  merged = [str(part).strip() for part in parts if str(part or "").strip()]