ask-marcel-office-cli 1.5.1 → 1.5.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `ask-marcel-office-cli` are documented here.
4
4
 
5
+ ## 1.5.2
6
+
7
+ ### Fixed
8
+
9
+ - **`find-chats-with-user` now surfaces cross-tenant 1:1 chats.** An externally-homed
10
+ counterpart comes back from the Teams chat roster as a bare object-id — no name, no
11
+ email — so a name search could never match it, and a real, active 1:1 returned a
12
+ silent `matchCount: 0`. The command now hydrates every bare **direct (1:1)** chat via
13
+ `/chats/{id}/members` and re-runs the match, so a cross-tenant counterpart is found
14
+ even when they were already resolved in a meeting under a different identity. When
15
+ nothing matches but bare members remain, it returns a `hint` + `unresolvedMemberCount`
16
+ rather than a confidently-empty result.
17
+ - **`list-chat-members` reads on the basic Teams token** (`ChatMember.Read`) instead of
18
+ the login-only elevated (M365ChatClient) token, so it no longer fails with "Elevated
19
+ token expired" on the command path. `next-page` routes `/chats/{id}/members` cursors on
20
+ the basic token to match. Chat _metadata_ (`list-chats` / `get-chat`) still requires
21
+ the elevated token.
22
+
5
23
  ## 1.5.1
6
24
 
7
25
  ### Fixed
package/dist/cli.js CHANGED
@@ -2457,7 +2457,7 @@ import updateNotifier from "update-notifier";
2457
2457
  // package.json
2458
2458
  var package_default = {
2459
2459
  name: "ask-marcel-office-cli",
2460
- version: "1.5.1",
2460
+ version: "1.5.2",
2461
2461
  description: "Microsoft Graph CLI + library — typed Bun/TypeScript wrapper around 150+ Graph operations (read + on-the-fly PDF/markdown conversion + federated Microsoft Search) reachable from a Teams browser-OAuth token.",
2462
2462
  license: "MIT",
2463
2463
  author: "Vincent Delacourt <vincent.delacourt@adama-development.com>",
@@ -3658,12 +3658,12 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3658
3658
  };
3659
3659
  const recoverableElevatedFailureMessage = (reason) => {
3660
3660
  if (reason === "launch_timeout") {
3661
- return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
3661
+ return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
3662
3662
  }
3663
3663
  if (reason === "navigation_failed") {
3664
- return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / list-chat-members / historical-version downloads) will be unavailable.";
3664
+ return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / historical-version downloads) will be unavailable.";
3665
3665
  }
3666
- return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
3666
+ return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
3667
3667
  };
3668
3668
  const recaptureElevated = async () => {
3669
3669
  try {
@@ -3699,7 +3699,7 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
3699
3699
  return ok(validated.value);
3700
3700
  }
3701
3701
  if (!recaptureSecondaryViaBrowser)
3702
- return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, list-chat-members, download-drive-item-version") });
3702
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, download-drive-item-version") });
3703
3703
  return recaptureElevatedShared();
3704
3704
  };
3705
3705
  const freshChatsvcaggToken = (cached) => {
@@ -23014,7 +23014,7 @@ __export(exports_list_chat_members, {
23014
23014
  });
23015
23015
  var baseSchema21 = exports_external.object({ chatId: exports_external.string().min(1) });
23016
23016
  var CHAT_MEMBERS_ODATA_KEYS = ["skip", "select", "filter"];
23017
- var inner5 = buildElevatedPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
23017
+ var inner5 = buildPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
23018
23018
  var execute42 = async (graph, params) => {
23019
23019
  const result = await inner5.execute(graph, params);
23020
23020
  if (result.ok)
@@ -23049,8 +23049,7 @@ var meta44 = {
23049
23049
  ],
23050
23050
  example: "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
23051
23051
  responseShape: "collection of Microsoft Graph `conversationMember` resources under `value[]`",
23052
- pagination: true,
23053
- needsElevatedToken: true
23052
+ pagination: true
23054
23053
  };
23055
23054
 
23056
23055
  // src/use-cases/commands/list-drive-item-permissions.ts
@@ -24012,8 +24011,13 @@ __export(exports_next_page, {
24012
24011
  execute: () => execute74
24013
24012
  });
24014
24013
  var PREFIX = "https://graph.microsoft.com/v1.0";
24015
- var ELEVATED_PATH_PREFIXES = ["/me/chats", "/chats/"];
24016
- var requiresElevated = (path) => ELEVATED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
24014
+ var requiresElevated = (path) => {
24015
+ if (path.startsWith("/me/chats"))
24016
+ return true;
24017
+ if (path.startsWith("/chats/"))
24018
+ return !path.includes("/members");
24019
+ return false;
24020
+ };
24017
24021
  var schema74 = exports_external.object({
24018
24022
  url: exports_external.string().min(1).refine((v) => v.startsWith(`${PREFIX}/`), { message: `must be a Microsoft Graph v1.0 URL starting with ${PREFIX}/` })
24019
24023
  });
@@ -26631,15 +26635,43 @@ var projectMember = (m) => {
26631
26635
  out.userSubType = m.userSubType;
26632
26636
  return out;
26633
26637
  };
26634
- var execute107 = async (graph, params) => {
26635
- const parsed = schema107.safeParse(params);
26636
- if (!parsed.success)
26637
- return err({ type: "validation_error", message: formatZodError(parsed.error) });
26638
- const queryFolded = fold(parsed.data.name);
26639
- const pageSize = parsed.data.pageSize ?? "100";
26640
- const maxPages = Number(parsed.data.maxPages ?? "10");
26641
- const matched = [];
26642
- const seenChatIds = new Set;
26638
+ var isNameResolvable = (m) => {
26639
+ const fields = [m.displayName, m.email, m.userPrincipalName, m.givenName, m.surname];
26640
+ return fields.some((f) => typeof f === "string" && f.trim() !== "");
26641
+ };
26642
+ var bareMemberCount = (members) => members.filter((m) => !isNameResolvable(m)).length;
26643
+ var fromGraphMember = (g) => ({
26644
+ ...g.displayName !== undefined ? { displayName: g.displayName } : {},
26645
+ ...g.email !== undefined ? { email: g.email } : {},
26646
+ ...typeof g.userId === "string" ? { mri: `8:orgid:${g.userId}`, objectId: g.userId } : {}
26647
+ });
26648
+ var toMatchedChat = (chatId, chat, members) => ({
26649
+ chatId,
26650
+ title: chat.title ?? null,
26651
+ chatType: chat.chatType,
26652
+ threadType: chat.threadType,
26653
+ memberCount: (chat.members ?? []).length,
26654
+ ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
26655
+ matchedMembers: members.map(projectMember)
26656
+ });
26657
+ var collectChat = (chat, queryFolded, acc) => {
26658
+ if (chat.id === undefined || acc.seen.has(chat.id))
26659
+ return;
26660
+ const members = chat.members ?? [];
26661
+ const hits = members.filter((m) => memberMatches(m, queryFolded));
26662
+ if (hits.length > 0) {
26663
+ acc.seen.add(chat.id);
26664
+ acc.matched.push(toMatchedChat(chat.id, chat, hits));
26665
+ return;
26666
+ }
26667
+ const bareCount = bareMemberCount(members);
26668
+ if (bareCount > 0) {
26669
+ acc.seen.add(chat.id);
26670
+ acc.bareUnmatched.push({ chatId: chat.id, chat, bareCount });
26671
+ }
26672
+ };
26673
+ var scanChatPages = async (graph, queryFolded, pageSize, maxPages) => {
26674
+ const acc = { seen: new Set, matched: [], bareUnmatched: [] };
26643
26675
  let continuationToken;
26644
26676
  let pagesFetched = 0;
26645
26677
  let chatsScanned = 0;
@@ -26654,42 +26686,67 @@ var execute107 = async (graph, params) => {
26654
26686
  const chats = body.chats ?? [];
26655
26687
  pagesFetched += 1;
26656
26688
  chatsScanned += chats.length;
26657
- for (const chat of chats) {
26658
- if (chat.id === undefined || seenChatIds.has(chat.id))
26659
- continue;
26660
- const members = chat.members ?? [];
26661
- const matchedMembers = members.filter((m) => memberMatches(m, queryFolded));
26662
- if (matchedMembers.length === 0)
26663
- continue;
26664
- seenChatIds.add(chat.id);
26665
- matched.push({
26666
- chatId: chat.id,
26667
- title: chat.title ?? null,
26668
- chatType: chat.chatType,
26669
- threadType: chat.threadType,
26670
- memberCount: members.length,
26671
- ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
26672
- matchedMembers: matchedMembers.map(projectMember)
26673
- });
26674
- }
26689
+ for (const chat of chats)
26690
+ collectChat(chat, queryFolded, acc);
26675
26691
  if (body.hasMoreData !== true || body.continuationToken === undefined) {
26676
26692
  continuationToken = undefined;
26677
26693
  break;
26678
26694
  }
26679
26695
  continuationToken = body.continuationToken;
26680
26696
  }
26697
+ return ok({ matched: acc.matched, bareUnmatched: acc.bareUnmatched, pagesFetched, chatsScanned, continuationToken });
26698
+ };
26699
+ var sumBareCounts = (bare) => bare.reduce((n, b) => n + b.bareCount, 0);
26700
+ var hydrateMatches = async (graph, chatId, queryFolded) => {
26701
+ const res = await graph.get(`/chats/${chatId}/members`);
26702
+ if (!res.ok)
26703
+ return null;
26704
+ const value = res.value.value ?? [];
26705
+ return value.map(fromGraphMember).filter((m) => memberMatches(m, queryFolded));
26706
+ };
26707
+ var isDirectChat = (chatId) => chatId.endsWith("@unq.gbl.spaces");
26708
+ var hydrateBareDirect = async (graph, queryFolded, bareUnmatched, matched) => {
26709
+ const direct = bareUnmatched.filter((b) => isDirectChat(b.chatId));
26710
+ let unresolvedMemberCount = sumBareCounts(bareUnmatched.filter((b) => !isDirectChat(b.chatId)));
26711
+ const results = await Promise.all(direct.map(async (b) => ({ bare: b, hits: await hydrateMatches(graph, b.chatId, queryFolded) })));
26712
+ for (const { bare, hits } of results) {
26713
+ if (hits === null) {
26714
+ unresolvedMemberCount += bare.bareCount;
26715
+ continue;
26716
+ }
26717
+ if (hits.length > 0)
26718
+ matched.push(toMatchedChat(bare.chatId, bare.chat, hits));
26719
+ }
26720
+ return { chatsHydrated: direct.length, unresolvedMemberCount };
26721
+ };
26722
+ var HINT2 = "No chat member matched by name, but at least one chat has a cross-tenant member the Teams roster left unresolved — an externally-homed counterpart often appears only as a bare object-id. Direct 1:1 chats were deep-probed; members in group/meeting chats were not. Retry searching by their object-id (pass it as `--name <object-id>`), or, if you have the chat URL, read it directly with `get-chat` / `list-teams-chat-messages --chat-id 19:<their-oid>_<your-oid>@unq.gbl.spaces`.";
26723
+ var execute107 = async (graph, params) => {
26724
+ const parsed = schema107.safeParse(params);
26725
+ if (!parsed.success)
26726
+ return err({ type: "validation_error", message: formatZodError(parsed.error) });
26727
+ const queryFolded = fold(parsed.data.name);
26728
+ const pageSize = parsed.data.pageSize ?? "100";
26729
+ const maxPages = Number(parsed.data.maxPages ?? "20");
26730
+ const scan = await scanChatPages(graph, queryFolded, pageSize, maxPages);
26731
+ if (!scan.ok)
26732
+ return scan;
26733
+ const { matched, bareUnmatched, pagesFetched, chatsScanned, continuationToken } = scan.value;
26734
+ const { chatsHydrated, unresolvedMemberCount } = await hydrateBareDirect(graph, queryFolded, bareUnmatched, matched);
26681
26735
  return ok({
26682
26736
  name: parsed.data.name,
26683
26737
  matches: matched,
26684
26738
  matchCount: matched.length,
26685
26739
  pagesFetched,
26686
26740
  chatsScanned,
26741
+ chatsHydrated,
26742
+ unresolvedMemberCount,
26687
26743
  hasMore: continuationToken !== undefined,
26688
- nextContinuationToken: continuationToken
26744
+ nextContinuationToken: continuationToken,
26745
+ ...matched.length === 0 && unresolvedMemberCount > 0 ? { hint: HINT2 } : {}
26689
26746
  });
26690
26747
  };
26691
26748
  var meta109 = {
26692
- summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
26749
+ summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
26693
26750
  category: "chats",
26694
26751
  needsSubstrateToken: true,
26695
26752
  graphMethod: "GET",
@@ -26706,7 +26763,7 @@ var meta109 = {
26706
26763
  name: "max-pages",
26707
26764
  key: "maxPages",
26708
26765
  required: false,
26709
- description: "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
26766
+ description: "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
26710
26767
  },
26711
26768
  {
26712
26769
  name: "page-size",
@@ -26716,7 +26773,7 @@ var meta109 = {
26716
26773
  }
26717
26774
  ],
26718
26775
  example: "ask-marcel find-chats-with-user --name 'Jane DOE'",
26719
- responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
26776
+ responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, chatsHydrated, unresolvedMemberCount, hasMore, nextContinuationToken?, hint? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `chatsHydrated` counts the per-chat members lookups spent resolving bare cross-tenant members in direct (1:1) chats. `unresolvedMemberCount` is how many cross-tenant members are still unresolved by name (bare members in group/meeting chats, which are not deep-probed, plus any 1:1 hydration that errored); when `matchCount` is 0 and this is non-zero, a `hint` is present explaining the likely cause and the object-id / read-by-chat-id remedy — so an empty result is never silently confident. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
26720
26777
  stability: "experimental"
26721
26778
  };
26722
26779
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "ask-marcel-office-cli",
3
- "version": "1.5.1",
4
- "generatedAt": "2026-06-22T01:10:59.323Z",
3
+ "version": "1.5.2",
4
+ "generatedAt": "2026-06-23T11:35:14.255Z",
5
5
  "commands": [
6
6
  {
7
7
  "name": "convert-calendar-event-attachment-to-markdown",
@@ -666,7 +666,7 @@
666
666
  },
667
667
  {
668
668
  "name": "find-chats-with-user",
669
- "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
669
+ "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
670
670
  "category": "chats",
671
671
  "graphMethod": "GET",
672
672
  "graphPathTemplate": "https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats",
@@ -682,7 +682,7 @@
682
682
  "name": "max-pages",
683
683
  "key": "maxPages",
684
684
  "required": false,
685
- "description": "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
685
+ "description": "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
686
686
  },
687
687
  {
688
688
  "name": "page-size",
@@ -692,7 +692,7 @@
692
692
  }
693
693
  ],
694
694
  "example": "ask-marcel find-chats-with-user --name 'Jane DOE'",
695
- "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
695
+ "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, chatsHydrated, unresolvedMemberCount, hasMore, nextContinuationToken?, hint? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `chatsHydrated` counts the per-chat members lookups spent resolving bare cross-tenant members in direct (1:1) chats. `unresolvedMemberCount` is how many cross-tenant members are still unresolved by name (bare members in group/meeting chats, which are not deep-probed, plus any 1:1 hydration that errored); when `matchCount` is 0 and this is non-zero, a `hint` is present explaining the likely cause and the object-id / read-by-chat-id remedy — so an empty result is never silently confident. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
696
696
  "needsSubstrateToken": true,
697
697
  "stability": "experimental"
698
698
  },
@@ -2987,8 +2987,7 @@
2987
2987
  ],
2988
2988
  "example": "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
2989
2989
  "responseShape": "collection of Microsoft Graph `conversationMember` resources under `value[]`",
2990
- "pagination": true,
2991
- "needsElevatedToken": true
2990
+ "pagination": true
2992
2991
  },
2993
2992
  {
2994
2993
  "name": "list-chats",
package/dist/index.js CHANGED
@@ -1432,12 +1432,12 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1432
1432
  };
1433
1433
  const recoverableElevatedFailureMessage = (reason) => {
1434
1434
  if (reason === "launch_timeout") {
1435
- return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
1435
+ return "elevated browser launch timed out (15s) — likely a corrupt persistent profile or filesystem lock. Run `ask-marcel logout && ask-marcel login` to wipe the profile and retry. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
1436
1436
  }
1437
1437
  if (reason === "navigation_failed") {
1438
- return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / list-chat-members / historical-version downloads) will be unavailable.";
1438
+ return "elevated capture failed: navigation to m365.cloud.microsoft did not complete — network issue, corp-proxy block, or tenant policy. Check connectivity and retry. If persistent, the elevated commands (list-chats / get-chat / historical-version downloads) will be unavailable.";
1439
1439
  }
1440
- return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, list-chat-members, the historical-version download / convert commands.)";
1440
+ return "elevated token capture timed out — silent SSO against m365.cloud.microsoft did not yield a Bearer within 20s. The persistent browser-profile cookies are likely expired. Run `ask-marcel logout && ask-marcel login` — this now wipes the profile too. (Commands that need this token: list-chats, get-chat, the historical-version download / convert commands.)";
1441
1441
  };
1442
1442
  const recaptureElevated = async () => {
1443
1443
  try {
@@ -1473,7 +1473,7 @@ var createAuthManagerFromApi = (browserAuth, cachePath, browserProfileDir, logge
1473
1473
  return ok(validated.value);
1474
1474
  }
1475
1475
  if (!recaptureSecondaryViaBrowser)
1476
- return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, list-chat-members, download-drive-item-version") });
1476
+ return err({ type: "auth_failed", message: failFastSecondaryMessage("Elevated (M365)", "list-chats, get-chat, download-drive-item-version") });
1477
1477
  return recaptureElevatedShared();
1478
1478
  };
1479
1479
  const freshChatsvcaggToken = (cached) => {
@@ -19906,7 +19906,7 @@ __export(exports_list_chat_members, {
19906
19906
  });
19907
19907
  var baseSchema21 = exports_external.object({ chatId: exports_external.string().min(1) });
19908
19908
  var CHAT_MEMBERS_ODATA_KEYS = ["skip", "select", "filter"];
19909
- var inner5 = buildElevatedPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
19909
+ var inner5 = buildPickODataListCommand((p) => `/chats/${p.chatId}/members`, baseSchema21, CHAT_MEMBERS_ODATA_KEYS);
19910
19910
  var execute42 = async (graph, params) => {
19911
19911
  const result = await inner5.execute(graph, params);
19912
19912
  if (result.ok)
@@ -19941,8 +19941,7 @@ var meta44 = {
19941
19941
  ],
19942
19942
  example: "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
19943
19943
  responseShape: "collection of Microsoft Graph `conversationMember` resources under `value[]`",
19944
- pagination: true,
19945
- needsElevatedToken: true
19944
+ pagination: true
19946
19945
  };
19947
19946
 
19948
19947
  // src/use-cases/commands/list-drive-item-permissions.ts
@@ -20904,8 +20903,13 @@ __export(exports_next_page, {
20904
20903
  execute: () => execute74
20905
20904
  });
20906
20905
  var PREFIX = "https://graph.microsoft.com/v1.0";
20907
- var ELEVATED_PATH_PREFIXES = ["/me/chats", "/chats/"];
20908
- var requiresElevated = (path) => ELEVATED_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
20906
+ var requiresElevated = (path) => {
20907
+ if (path.startsWith("/me/chats"))
20908
+ return true;
20909
+ if (path.startsWith("/chats/"))
20910
+ return !path.includes("/members");
20911
+ return false;
20912
+ };
20909
20913
  var schema74 = exports_external.object({
20910
20914
  url: exports_external.string().min(1).refine((v) => v.startsWith(`${PREFIX}/`), { message: `must be a Microsoft Graph v1.0 URL starting with ${PREFIX}/` })
20911
20915
  });
@@ -23523,15 +23527,43 @@ var projectMember = (m) => {
23523
23527
  out.userSubType = m.userSubType;
23524
23528
  return out;
23525
23529
  };
23526
- var execute107 = async (graph, params) => {
23527
- const parsed = schema107.safeParse(params);
23528
- if (!parsed.success)
23529
- return err({ type: "validation_error", message: formatZodError(parsed.error) });
23530
- const queryFolded = fold(parsed.data.name);
23531
- const pageSize = parsed.data.pageSize ?? "100";
23532
- const maxPages = Number(parsed.data.maxPages ?? "10");
23533
- const matched = [];
23534
- const seenChatIds = new Set;
23530
+ var isNameResolvable = (m) => {
23531
+ const fields = [m.displayName, m.email, m.userPrincipalName, m.givenName, m.surname];
23532
+ return fields.some((f) => typeof f === "string" && f.trim() !== "");
23533
+ };
23534
+ var bareMemberCount = (members) => members.filter((m) => !isNameResolvable(m)).length;
23535
+ var fromGraphMember = (g) => ({
23536
+ ...g.displayName !== undefined ? { displayName: g.displayName } : {},
23537
+ ...g.email !== undefined ? { email: g.email } : {},
23538
+ ...typeof g.userId === "string" ? { mri: `8:orgid:${g.userId}`, objectId: g.userId } : {}
23539
+ });
23540
+ var toMatchedChat = (chatId, chat, members) => ({
23541
+ chatId,
23542
+ title: chat.title ?? null,
23543
+ chatType: chat.chatType,
23544
+ threadType: chat.threadType,
23545
+ memberCount: (chat.members ?? []).length,
23546
+ ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
23547
+ matchedMembers: members.map(projectMember)
23548
+ });
23549
+ var collectChat = (chat, queryFolded, acc) => {
23550
+ if (chat.id === undefined || acc.seen.has(chat.id))
23551
+ return;
23552
+ const members = chat.members ?? [];
23553
+ const hits = members.filter((m) => memberMatches(m, queryFolded));
23554
+ if (hits.length > 0) {
23555
+ acc.seen.add(chat.id);
23556
+ acc.matched.push(toMatchedChat(chat.id, chat, hits));
23557
+ return;
23558
+ }
23559
+ const bareCount = bareMemberCount(members);
23560
+ if (bareCount > 0) {
23561
+ acc.seen.add(chat.id);
23562
+ acc.bareUnmatched.push({ chatId: chat.id, chat, bareCount });
23563
+ }
23564
+ };
23565
+ var scanChatPages = async (graph, queryFolded, pageSize, maxPages) => {
23566
+ const acc = { seen: new Set, matched: [], bareUnmatched: [] };
23535
23567
  let continuationToken;
23536
23568
  let pagesFetched = 0;
23537
23569
  let chatsScanned = 0;
@@ -23546,42 +23578,67 @@ var execute107 = async (graph, params) => {
23546
23578
  const chats = body.chats ?? [];
23547
23579
  pagesFetched += 1;
23548
23580
  chatsScanned += chats.length;
23549
- for (const chat of chats) {
23550
- if (chat.id === undefined || seenChatIds.has(chat.id))
23551
- continue;
23552
- const members = chat.members ?? [];
23553
- const matchedMembers = members.filter((m) => memberMatches(m, queryFolded));
23554
- if (matchedMembers.length === 0)
23555
- continue;
23556
- seenChatIds.add(chat.id);
23557
- matched.push({
23558
- chatId: chat.id,
23559
- title: chat.title ?? null,
23560
- chatType: chat.chatType,
23561
- threadType: chat.threadType,
23562
- memberCount: members.length,
23563
- ...chat.lastMessage?.composeTime !== undefined ? { lastMessageAt: chat.lastMessage.composeTime } : {},
23564
- matchedMembers: matchedMembers.map(projectMember)
23565
- });
23566
- }
23581
+ for (const chat of chats)
23582
+ collectChat(chat, queryFolded, acc);
23567
23583
  if (body.hasMoreData !== true || body.continuationToken === undefined) {
23568
23584
  continuationToken = undefined;
23569
23585
  break;
23570
23586
  }
23571
23587
  continuationToken = body.continuationToken;
23572
23588
  }
23589
+ return ok({ matched: acc.matched, bareUnmatched: acc.bareUnmatched, pagesFetched, chatsScanned, continuationToken });
23590
+ };
23591
+ var sumBareCounts = (bare) => bare.reduce((n, b) => n + b.bareCount, 0);
23592
+ var hydrateMatches = async (graph, chatId, queryFolded) => {
23593
+ const res = await graph.get(`/chats/${chatId}/members`);
23594
+ if (!res.ok)
23595
+ return null;
23596
+ const value = res.value.value ?? [];
23597
+ return value.map(fromGraphMember).filter((m) => memberMatches(m, queryFolded));
23598
+ };
23599
+ var isDirectChat = (chatId) => chatId.endsWith("@unq.gbl.spaces");
23600
+ var hydrateBareDirect = async (graph, queryFolded, bareUnmatched, matched) => {
23601
+ const direct = bareUnmatched.filter((b) => isDirectChat(b.chatId));
23602
+ let unresolvedMemberCount = sumBareCounts(bareUnmatched.filter((b) => !isDirectChat(b.chatId)));
23603
+ const results = await Promise.all(direct.map(async (b) => ({ bare: b, hits: await hydrateMatches(graph, b.chatId, queryFolded) })));
23604
+ for (const { bare, hits } of results) {
23605
+ if (hits === null) {
23606
+ unresolvedMemberCount += bare.bareCount;
23607
+ continue;
23608
+ }
23609
+ if (hits.length > 0)
23610
+ matched.push(toMatchedChat(bare.chatId, bare.chat, hits));
23611
+ }
23612
+ return { chatsHydrated: direct.length, unresolvedMemberCount };
23613
+ };
23614
+ var HINT2 = "No chat member matched by name, but at least one chat has a cross-tenant member the Teams roster left unresolved — an externally-homed counterpart often appears only as a bare object-id. Direct 1:1 chats were deep-probed; members in group/meeting chats were not. Retry searching by their object-id (pass it as `--name <object-id>`), or, if you have the chat URL, read it directly with `get-chat` / `list-teams-chat-messages --chat-id 19:<their-oid>_<your-oid>@unq.gbl.spaces`.";
23615
+ var execute107 = async (graph, params) => {
23616
+ const parsed = schema107.safeParse(params);
23617
+ if (!parsed.success)
23618
+ return err({ type: "validation_error", message: formatZodError(parsed.error) });
23619
+ const queryFolded = fold(parsed.data.name);
23620
+ const pageSize = parsed.data.pageSize ?? "100";
23621
+ const maxPages = Number(parsed.data.maxPages ?? "20");
23622
+ const scan = await scanChatPages(graph, queryFolded, pageSize, maxPages);
23623
+ if (!scan.ok)
23624
+ return scan;
23625
+ const { matched, bareUnmatched, pagesFetched, chatsScanned, continuationToken } = scan.value;
23626
+ const { chatsHydrated, unresolvedMemberCount } = await hydrateBareDirect(graph, queryFolded, bareUnmatched, matched);
23573
23627
  return ok({
23574
23628
  name: parsed.data.name,
23575
23629
  matches: matched,
23576
23630
  matchCount: matched.length,
23577
23631
  pagesFetched,
23578
23632
  chatsScanned,
23633
+ chatsHydrated,
23634
+ unresolvedMemberCount,
23579
23635
  hasMore: continuationToken !== undefined,
23580
- nextContinuationToken: continuationToken
23636
+ nextContinuationToken: continuationToken,
23637
+ ...matched.length === 0 && unresolvedMemberCount > 0 ? { hint: HINT2 } : {}
23581
23638
  });
23582
23639
  };
23583
23640
  var meta109 = {
23584
- summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
23641
+ summary: 'Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.',
23585
23642
  category: "chats",
23586
23643
  needsSubstrateToken: true,
23587
23644
  graphMethod: "GET",
@@ -23598,7 +23655,7 @@ var meta109 = {
23598
23655
  name: "max-pages",
23599
23656
  key: "maxPages",
23600
23657
  required: false,
23601
- description: "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
23658
+ description: "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
23602
23659
  },
23603
23660
  {
23604
23661
  name: "page-size",
@@ -23608,7 +23665,7 @@ var meta109 = {
23608
23665
  }
23609
23666
  ],
23610
23667
  example: "ask-marcel find-chats-with-user --name 'Jane DOE'",
23611
- responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
23668
+ responseShape: "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, chatsHydrated, unresolvedMemberCount, hasMore, nextContinuationToken?, hint? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `chatsHydrated` counts the per-chat members lookups spent resolving bare cross-tenant members in direct (1:1) chats. `unresolvedMemberCount` is how many cross-tenant members are still unresolved by name (bare members in group/meeting chats, which are not deep-probed, plus any 1:1 hydration that errored); when `matchCount` is 0 and this is non-zero, a `hint` is present explaining the likely cause and the object-id / read-by-chat-id remedy — so an empty result is never silently confident. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
23612
23669
  stability: "experimental"
23613
23670
  };
23614
23671
 
package/docs/COMMANDS.md CHANGED
@@ -223,7 +223,7 @@ For everything else:
223
223
 
224
224
  | Command | Description | Required params | Graph endpoint |
225
225
  |---------|-------------|-----------------|----------------|
226
- | `find-chats-with-user` | Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. | `--name`, `--max-pages`, `--page-size` | `GET https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats` |
226
+ | `find-chats-with-user` | Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical "all conversations with person X" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. | `--name`, `--max-pages`, `--page-size` | `GET https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats` |
227
227
  | `get-chat` | Return metadata for a single Microsoft Teams chat (1:1, group, or meeting). The CLI ships a slim default `--select=id,topic,chatType,createdDateTime,lastUpdatedDateTime`; pass `--select id,topic,webUrl,onlineMeetingInfo` (or any other comma-separated field list) to widen. Pass `--expand members` to inline membership. Returns metadata only — not the messages (which need `Chat.Read*`). Requires the M365ChatClient elevated token captured at login (the basic Teams web client token lacks `Chat.ReadBasic`). | `--chat-id`, `--select`, `--expand` | `GET /chats/{chat-id}` |
228
228
  | `get-teams-chat-message` | Return a single Microsoft Teams chat message by its id via the chat substrate. Uses the chatsvcagg-audience bearer captured at login (same identity as the basic Teams token, different audience). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API. Source the chat-id + message-id via `list-teams-chats-with-messages` or `list-teams-chat-messages`. | `--chat-id`, `--message-id` | `GET https://teams.microsoft.com/api/csa/{region}/api/v1/chats/{chat-id}/messages/{message-id}` |
229
229
  | `list-chat-members` | List the members of a single Microsoft Teams chat. Graph rejects `$top` / `$orderby` / `$expand` on this endpoint, so the CLI advertises only the subset Graph honours (`--skip`, `--select`, `--filter`). | `--chat-id`, `--skip`, `--select`, `--filter` | `GET /chats/{chat-id}/members` |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "ask-marcel-office-cli",
3
- "version": "1.5.1",
4
- "generatedAt": "2026-06-22T01:10:59.323Z",
3
+ "version": "1.5.2",
4
+ "generatedAt": "2026-06-23T11:35:14.255Z",
5
5
  "commands": [
6
6
  {
7
7
  "name": "convert-calendar-event-attachment-to-markdown",
@@ -666,7 +666,7 @@
666
666
  },
667
667
  {
668
668
  "name": "find-chats-with-user",
669
- "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
669
+ "summary": "Find every Microsoft Teams chat that includes a member matching `--name` (substring search across display-name, email, given-name, surname, MRI, and object-id). Both sides are Unicode-folded (NFD + combining-mark strip) and lowercased before comparison, so `--name Jane` matches `Jane DOE` AND `jane.doe@example.com` AND `JANE` — important because a dual-identity user often carries the accented display-name on one identity and the un-accented email on the other. Walks the paginated chat-list substrate up to `--max-pages` and returns matching chats with their `matchedMembers[]`. Collapses the canonical \"all conversations with person X\" workflow into a single call AND surfaces dual-identity people (e.g. someone with both an org MRI and a guest-tenant MRI). Cross-tenant resolution: the summary roster returns externally-homed counterparts as a bare object-id (no name/email), which a name search cannot match; for every bare DIRECT (1:1) chat the command hydrates the roster via the per-chat members endpoint and re-matches — so an external counterpart who is bare in your 1:1 is still found, even when they were already resolved in some meeting (the dual-identity case). Bare members in group/meeting chats are not deep-probed; when nothing matches and such members exist it returns a `hint` plus `unresolvedMemberCount` rather than a confident empty result. **Best-effort, may break on Microsoft client updates** — the chat substrate is not in the public Microsoft Graph API.",
670
670
  "category": "chats",
671
671
  "graphMethod": "GET",
672
672
  "graphPathTemplate": "https://teams.microsoft.com/api/csa/{region}/api/v3/teams/users/me/chats",
@@ -682,7 +682,7 @@
682
682
  "name": "max-pages",
683
683
  "key": "maxPages",
684
684
  "required": false,
685
- "description": "Safety cap on the chat-list walk (positive integer; default 10). Each page returns up to `--page-size` chats. Raise carefully on busy accounts every page is one HTTP round-trip."
685
+ "description": "Safety cap on the chat-list walk (positive integer; default 20). The walk stops early once the substrate reports no more pages, so the cap only bites on accounts with more chats than `--max-pages × --page-size`; raise it (and watch `hasMore`) if a known chat is missed. Each page is one HTTP round-trip."
686
686
  },
687
687
  {
688
688
  "name": "page-size",
@@ -692,7 +692,7 @@
692
692
  }
693
693
  ],
694
694
  "example": "ask-marcel find-chats-with-user --name 'Jane DOE'",
695
- "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, hasMore, nextContinuationToken? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
695
+ "responseShape": "`{ name, matches: [{ chatId, title, chatType, threadType, memberCount, lastMessageAt?, matchedMembers: [{ mri, displayName, email, userSubType }] }], matchCount, pagesFetched, chatsScanned, chatsHydrated, unresolvedMemberCount, hasMore, nextContinuationToken?, hint? }`. `matchedMembers` always carries the matching entries' identifying fields — pass `chatId` into `list-teams-chat-history` to read message bodies. `chatsHydrated` counts the per-chat members lookups spent resolving bare cross-tenant members in direct (1:1) chats. `unresolvedMemberCount` is how many cross-tenant members are still unresolved by name (bare members in group/meeting chats, which are not deep-probed, plus any 1:1 hydration that errored); when `matchCount` is 0 and this is non-zero, a `hint` is present explaining the likely cause and the object-id / read-by-chat-id remedy — so an empty result is never silently confident. `hasMore: true` means `--max-pages` was hit before exhausting the chat list; chain with the existing `--continuation-token` flag on `list-teams-chats-with-messages` if you need to scan further (this command does not advertise a `--continuation-token` because resuming a partial search is rare; users either widen `--max-pages` or refine `--name`).",
696
696
  "needsSubstrateToken": true,
697
697
  "stability": "experimental"
698
698
  },
@@ -2987,8 +2987,7 @@
2987
2987
  ],
2988
2988
  "example": "ask-marcel list-chat-members --chat-id '19:abc...@thread.v2'",
2989
2989
  "responseShape": "collection of Microsoft Graph `conversationMember` resources under `value[]`",
2990
- "pagination": true,
2991
- "needsElevatedToken": true
2990
+ "pagination": true
2992
2991
  },
2993
2992
  {
2994
2993
  "name": "list-chats",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-marcel-office-cli",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Microsoft Graph CLI + library \u2014 typed Bun/TypeScript wrapper around 150+ Graph operations (read + on-the-fly PDF/markdown conversion + federated Microsoft Search) reachable from a Teams browser-OAuth token.",
5
5
  "license": "MIT",
6
6
  "author": "Vincent Delacourt <vincent.delacourt@adama-development.com>",