lex-microsoft_teams 0.5.6 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d414b08d6eaabf909ab4de6fe900cc879bdeda369ea386925c2c3258ed9d4a24
4
- data.tar.gz: 8bdf0da6fb7f666eb9a4489180370e5f4e3124104e9c8736d85e9d0dbe1afbb5
3
+ metadata.gz: 01446eb9c4949be87da72534f700b4df999d654c479807e10afe77d2c8d5f584
4
+ data.tar.gz: 6d749b6ee7d420fedfa517dbeb73ac321c2fef90333a6b4aaca65f1df6195aed
5
5
  SHA512:
6
- metadata.gz: 02b042dea9cc6292403cc504c6bdcfde101e63c24c7120300985a0825ea3dcf11aa988705f64ad9ff5bace61513b4b621f263740f7ce9e40e858b45123017acb
7
- data.tar.gz: 9d09c3aa2518ccc19655d864c393f01ff2a578c7085c47ff4dfa130b1fb8dcec6a6cb465b2c3ae57787617201f06f9528006f60d6c0373860e2191c5b9a3216d
6
+ metadata.gz: 8ed0d0cf1be02569570bcf7d415f6bd490358466f88f2b908166f4a3756c116073ce24959c01d43608a3d9bb3783a1fd9002394410692b07b70e574ae34b9967
7
+ data.tar.gz: 18ba3b8cbcdc980e871fae2824a5c24ce5752d6aa1b7c836c3c94351d68c05e94927ae9d995f3f0f3465e601b711f563fd243df743b1c15a1045332150725527
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-03-20
4
+
5
+ ### Added
6
+ - `Runners::People` with `get_profile` and `list_people` (Graph API `/me` and `/me/people`)
7
+ - `Runners::ProfileIngest` four-phase pipeline (self, people, conversations, teams/meetings)
8
+ - `Helpers::PermissionGuard` circuit breaker for 403 errors with exponential backoff
9
+ - `Helpers::TransformDefinitions` for lex-transformer conversation extraction and person summary
10
+ - `Actors::ProfileIngest` (Once): four-phase data pipeline at boot after auth
11
+ - `Actors::IncrementalSync` (Every, 15min): periodic re-sync with HWM dedup
12
+ - `CLI::Auth` module for `legion lex teams auth login/status`
13
+ - Extended high-water mark with dual timestamps and procedural trace persistence
14
+ - `People.Read` delegated permission scope
15
+
16
+ ### Changed
17
+ - `Helpers::HighWaterMark` extended with `get/set/update_extended_hwm`, trace persistence, restore
18
+
3
19
  ## [0.5.6] - 2026-03-19
4
20
 
5
21
  ### Added
data/CLAUDE.md CHANGED
@@ -10,7 +10,7 @@ Legion Extension that connects LegionIO to Microsoft Teams via Graph API and Bot
10
10
 
11
11
  **GitHub**: https://github.com/LegionIO/lex-microsoft_teams
12
12
  **License**: MIT
13
- **Version**: 0.5.5
13
+ **Version**: 0.6.0
14
14
 
15
15
  ## Architecture
16
16
 
@@ -30,7 +30,9 @@ Legion::Extensions::MicrosoftTeams
30
30
  │ ├── Meetings # Online meeting CRUD, join URL lookup, attendance reports
31
31
  │ ├── Transcripts # Meeting transcript list/get/content (VTT/DOCX)
32
32
  │ ├── LocalCache # Offline message extraction from local LevelDB cache
33
- └── CacheIngest # Ingest cached messages into lex-memory as episodic traces
33
+ ├── CacheIngest # Ingest cached messages into lex-memory as episodic traces
34
+ │ ├── People # Graph API /me and /me/people (profile + relevant contacts)
35
+ │ └── ProfileIngest # Four-phase cognitive pipeline (self, people, conversations, teams/meetings)
34
36
  ├── Actors/
35
37
  │ ├── CacheBulkIngest # Once: full cache ingest at startup (imprint window support)
36
38
  │ ├── CacheSync # Every 5min: incremental ingest of new messages
@@ -38,7 +40,9 @@ Legion::Extensions::MicrosoftTeams
38
40
  │ ├── ObservedChatPoller # Every 30s: polls subscribed human conversations (compliance-gated)
39
41
  │ ├── MessageProcessor # Subscription: consumes AMQP queue, routes by mode
40
42
  │ ├── AuthValidator # Once: validates/restores delegated tokens on boot (2s delay)
41
- └── TokenRefresher # Every 15min (configurable): keeps delegated tokens fresh
43
+ ├── TokenRefresher # Every 15min (configurable): keeps delegated tokens fresh
44
+ │ ├── ProfileIngest # Once (5s delay): four-phase data pipeline after auth
45
+ │ └── IncrementalSync # Every 15min: periodic re-sync with HWM dedup
42
46
  ├── Transport/
43
47
  │ ├── Exchanges/Messages # teams.messages topic exchange
44
48
  │ ├── Queues/MessagesProcess # teams.messages.process durable queue
@@ -54,10 +58,14 @@ Legion::Extensions::MicrosoftTeams
54
58
  │ ├── SessionManager # Multi-turn LLM session lifecycle with lex-memory persistence
55
59
  │ ├── TokenCache # In-memory OAuth token cache with pre-expiry refresh (app + delegated slots, authenticated?/previously_authenticated? predicates)
56
60
  │ ├── SubscriptionRegistry # Conversation observation subscriptions (in-memory + lex-memory)
57
- │ ├── BrowserAuth # Delegated OAuth orchestrator (PKCE, headless detection, browser launch)
58
- └── CallbackServer # Ephemeral TCP server for OAuth redirect callback
61
+ │ ├── BrowserAuth # Delegated OAuth orchestrator (PKCE, headless detection, browser launch, API hook detection)
62
+ ├── CallbackServer # Ephemeral TCP server for OAuth redirect callback
63
+ │ ├── PermissionGuard # Circuit breaker for 403 errors with exponential backoff
64
+ │ └── TransformDefinitions # lex-transformer definitions for conversation extraction and person summary
59
65
  ├── Hooks/
60
66
  │ └── Auth # OAuth callback hook (mount '/callback') → /api/hooks/lex/microsoft_teams/auth/callback
67
+ ├── CLI/
68
+ │ └── Auth # CLI module for `legion lex teams auth login/status`
61
69
  └── Client # Standalone client (includes all runners)
62
70
  ```
63
71
 
@@ -65,7 +73,7 @@ Legion::Extensions::MicrosoftTeams
65
73
 
66
74
  Opt-in browser-based OAuth for delegated Microsoft Graph permissions. Two flows:
67
75
 
68
- - **Authorization Code + PKCE** (primary): Opens browser for Entra ID login, captures callback on ephemeral local port, exchanges code with PKCE verification
76
+ - **Authorization Code + PKCE** (primary): Opens browser for Entra ID login. When the Legion API is running, uses the hook URL (`/api/hooks/lex/microsoft_teams/auth/callback`) with `Legion::Events` for callback notification; otherwise falls back to an ephemeral local port via `CallbackServer`
69
77
  - **Device Code** (fallback): Auto-selected in headless/SSH environments (no `DISPLAY`/`WAYLAND_DISPLAY`)
70
78
 
71
79
  Tokens stored in Vault (`legionio/microsoft_teams/delegated_token`) with configurable pre-expiry silent refresh. CLI command: `legion auth teams`. Hook route: `GET|POST /api/hooks/lex/microsoft_teams/auth/callback` for daemon re-auth (routed through Ingress for RBAC/audit).
@@ -84,6 +92,33 @@ Configuration: `settings[:microsoft_teams][:auth][:delegated][:refresh_interval]
84
92
 
85
93
  Design doc: `docs/plans/2026-03-19-teams-token-lifecycle-design.md`
86
94
 
95
+ ## Cognitive Pipeline (v0.6.0)
96
+
97
+ Four-phase data ingestion that runs after delegated auth to build the agent's social context:
98
+
99
+ 1. **Self** (`ingest_self`): Fetches `/me` profile and `/me/presence`, stores as identity trace
100
+ 2. **People** (`ingest_people`): Fetches `/me/people` (top 25), stores each as semantic trace
101
+ 3. **Conversations** (`ingest_conversations`): For top N people, fetches recent chat messages, stores as episodic traces
102
+ 4. **Teams & Meetings** (`ingest_teams_and_meetings`): Fetches joined teams and recent meetings, stores as semantic + episodic traces
103
+
104
+ ### Actors
105
+
106
+ - **ProfileIngest** (Once, 5s delay): Fires `full_ingest` after boot. Only enabled when lex-memory is available and a delegated token exists.
107
+ - **IncrementalSync** (Every, 15min): Fires `incremental_sync` using extended high-water marks for dedup. Configurable via `settings[:microsoft_teams][:ingest][:incremental_interval]`.
108
+
109
+ ### Supporting Components
110
+
111
+ - **Runners::People**: Graph API `/me` and `/me/people` endpoints with `user_id:` flexibility
112
+ - **Helpers::PermissionGuard**: Circuit breaker for Graph API 403 errors with exponential backoff (60s → 5min → 30min → 2hr → 8hr cap). Wraps API calls via `guarded_request(endpoint) { block }`.
113
+ - **Helpers::TransformDefinitions**: Structured extraction schemas for lex-transformer (`conversation_extract`, `person_summary`)
114
+ - **Extended HWM**: `get/set/update_extended_hwm` with dual timestamps (last_message_at + last_ingested_at) and `persist_hwm_as_trace` / `restore_hwm_from_traces` for cross-boot memory
115
+
116
+ ### CLI
117
+
118
+ `CLI::Auth` provides `legion lex teams auth login` and `legion lex teams auth status` via the LEX CLI manifest system. Uses `cli_alias: 'teams'` for short-form dispatch.
119
+
120
+ Design doc: `docs/plans/2026-03-20-teams-cognitive-pipeline-implementation.md`
121
+
87
122
  ## AI Bot (v0.2.0)
88
123
 
89
124
  Two operating modes, both using polling (Graph API) with AMQP-based message routing:
@@ -153,7 +188,7 @@ Per-conversation overrides stored in lex-memory (system_prompt_append, llm model
153
188
  - **Session persistence**: Multi-turn sessions flush to lex-memory on threshold (20 msgs), idle timeout (15 min), or shutdown. Restored on restart via summary + recent messages.
154
189
  - **Token caching**: In-memory OAuth token cache refreshes 60 seconds before expiry. Both pollers share a `TokenCache` instance instead of hitting OAuth every cycle.
155
190
  - **Subscription registry**: In-memory working set of observed conversations, persisted to lex-memory on change. No legion-data migration needed.
156
- - **Design docs**: `docs/plans/2026-03-15-teams-ai-bot-design.md`, `docs/plans/2026-03-15-teams-bot-commands-design.md`
191
+ - **Design docs**: `docs/work/completed/2026-03-15-teams-ai-bot-design.md`, `docs/work/completed/2026-03-15-teams-bot-commands-design.md`
157
192
 
158
193
  ### Bot Commands (v0.3.0)
159
194
 
@@ -193,6 +228,7 @@ Four distinct APIs accessed via Faraday + one local data source:
193
228
  | `OnlineMeetings.Read` | Delegated | Read online meetings (user context) |
194
229
  | `OnlineMeetings.Read.All` | Application | Read online meetings |
195
230
  | `OnlineMeetingTranscript.Read.All` | Application/Delegated | Read meeting transcripts |
231
+ | `People.Read` | Delegated | Read relevant people for cognitive pipeline |
196
232
 
197
233
  For bot scenarios, register the Entra app as a Teams Bot via Bot Framework portal.
198
234
 
@@ -215,7 +251,7 @@ Optional framework dependencies (guarded with `defined?`, not in gemspec):
215
251
 
216
252
  ```bash
217
253
  bundle install
218
- bundle exec rspec # 219 specs (as of v0.5.5)
254
+ bundle exec rspec # 268 specs across 38 spec files (as of v0.6.0)
219
255
  bundle exec rubocop # Clean
220
256
  ```
221
257
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class IncrementalSync < Legion::Extensions::Actors::Every
8
+ def runner_class = Legion::Extensions::MicrosoftTeams::Runners::ProfileIngest
9
+ def runner_function = 'incremental_sync'
10
+ def use_runner? = false
11
+ def check_subtask? = false
12
+ def generate_task? = false
13
+ def run_now? = false
14
+
15
+ def delay
16
+ settings = begin
17
+ Legion::Settings[:microsoft_teams]
18
+ rescue StandardError
19
+ {}
20
+ end
21
+ settings.dig(:ingest, :incremental_interval) || 900
22
+ end
23
+
24
+ def enabled?
25
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
26
+ token_available?
27
+ rescue StandardError
28
+ false
29
+ end
30
+
31
+ def args
32
+ token = resolve_token
33
+ settings = begin
34
+ Legion::Settings[:microsoft_teams]
35
+ rescue StandardError
36
+ {}
37
+ end
38
+ ingest = settings[:ingest] || {}
39
+ {
40
+ token: token,
41
+ top_people: ingest.fetch(:top_people, 10),
42
+ message_depth: ingest.fetch(:message_depth, 50)
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def token_available?
49
+ resolve_token != nil
50
+ end
51
+
52
+ def resolve_token
53
+ if defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
54
+ cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
55
+ cache.cached_delegated_token
56
+ end
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class ProfileIngest < Legion::Extensions::Actors::Once
8
+ def runner_class = Legion::Extensions::MicrosoftTeams::Runners::ProfileIngest
9
+ def runner_function = 'full_ingest'
10
+ def use_runner? = false
11
+ def check_subtask? = false
12
+ def generate_task? = false
13
+
14
+ def delay
15
+ 5.0
16
+ end
17
+
18
+ def enabled?
19
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
20
+ token_available?
21
+ rescue StandardError
22
+ false
23
+ end
24
+
25
+ def args
26
+ token = resolve_token
27
+ settings = begin
28
+ Legion::Settings[:microsoft_teams]
29
+ rescue StandardError
30
+ {}
31
+ end
32
+ ingest = settings[:ingest] || {}
33
+ {
34
+ token: token,
35
+ top_people: ingest.fetch(:top_people, 10),
36
+ message_depth: ingest.fetch(:message_depth, 50)
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def token_available?
43
+ resolve_token != nil
44
+ end
45
+
46
+ def resolve_token
47
+ if defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
48
+ cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
49
+ cache.cached_delegated_token
50
+ end
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/microsoft_teams/helpers/browser_auth'
4
+ require 'legion/extensions/microsoft_teams/helpers/token_cache'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module MicrosoftTeams
9
+ module CLI
10
+ class Auth
11
+ def self.cli_alias
12
+ 'teams'
13
+ end
14
+
15
+ def self.descriptions
16
+ {
17
+ login: 'Authenticate with Microsoft Teams via browser OAuth',
18
+ status: 'Show current Teams authentication state'
19
+ }
20
+ end
21
+
22
+ def login(tenant_id: nil, client_id: nil)
23
+ settings = resolve_settings
24
+ tid = tenant_id || settings[:tenant_id]
25
+ cid = client_id || settings[:client_id]
26
+
27
+ unless tid && cid
28
+ puts 'Error: tenant_id and client_id required (set in settings or pass as args)'
29
+ return
30
+ end
31
+
32
+ browser_auth = Helpers::BrowserAuth.new(tenant_id: tid, client_id: cid)
33
+ result = browser_auth.authenticate
34
+
35
+ if result&.dig(:access_token)
36
+ store_token(result)
37
+ puts 'Teams authenticated successfully.'
38
+ else
39
+ puts 'Teams authentication failed or was cancelled.'
40
+ end
41
+ rescue StandardError => e
42
+ puts "Error: #{e.message}"
43
+ end
44
+
45
+ def status
46
+ token_file = File.expand_path('~/.legionio/tokens/microsoft_teams.json')
47
+ if File.exist?(token_file)
48
+ puts 'Teams: authenticated (token file present)'
49
+ else
50
+ puts 'Teams: not authenticated'
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def resolve_settings
57
+ return {} unless defined?(Legion::Settings)
58
+
59
+ Legion::Settings[:microsoft_teams]&.dig(:auth) || {}
60
+ rescue StandardError
61
+ {}
62
+ end
63
+
64
+ def store_token(result)
65
+ cache = Helpers::TokenCache.new
66
+ cache.store_delegated_token(result)
67
+ cache.save_to_vault
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -13,6 +13,7 @@ require 'legion/extensions/microsoft_teams/runners/bot'
13
13
  require 'legion/extensions/microsoft_teams/runners/presence'
14
14
  require 'legion/extensions/microsoft_teams/runners/meetings'
15
15
  require 'legion/extensions/microsoft_teams/runners/transcripts'
16
+ require 'legion/extensions/microsoft_teams/runners/people'
16
17
 
17
18
  module Legion
18
19
  module Extensions
@@ -33,6 +34,7 @@ module Legion
33
34
  include Runners::Transcripts
34
35
  include Runners::LocalCache
35
36
  include Runners::CacheIngest
37
+ include Runners::People
36
38
 
37
39
  attr_reader :opts
38
40
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module MicrosoftTeams
@@ -45,6 +47,77 @@ module Legion
45
47
  set_hwm(chat_id: chat_id, timestamp: latest)
46
48
  end
47
49
 
50
+ def get_extended_hwm(chat_id:)
51
+ key = "teams:ehwm:#{chat_id}"
52
+ raw = if cache_available?
53
+ Legion::Cache.get(key)
54
+ else
55
+ @ehwm_fallback ||= {}
56
+ @ehwm_fallback[key]
57
+ end
58
+ return nil unless raw
59
+
60
+ raw.is_a?(Hash) ? raw : ::JSON.parse(raw, symbolize_names: true)
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ def set_extended_hwm(chat_id:, last_message_at:, last_ingested_at:, message_count: 0)
66
+ key = "teams:ehwm:#{chat_id}"
67
+ value = { last_message_at: last_message_at, last_ingested_at: last_ingested_at,
68
+ message_count: message_count }
69
+ if cache_available?
70
+ Legion::Cache.set(key, ::JSON.dump(value), HWM_TTL)
71
+ else
72
+ @ehwm_fallback ||= {}
73
+ @ehwm_fallback[key] = value
74
+ end
75
+ end
76
+
77
+ def update_extended_hwm(chat_id:, last_message_at:, new_message_count: 0, ingested: false)
78
+ existing = get_extended_hwm(chat_id: chat_id) || { last_message_at: nil, last_ingested_at: nil, message_count: 0 }
79
+ existing[:last_message_at] = last_message_at
80
+ existing[:message_count] = (existing[:message_count] || 0) + new_message_count
81
+ existing[:last_ingested_at] = Time.now.utc.iso8601 if ingested
82
+ set_extended_hwm(chat_id: chat_id, **existing)
83
+ end
84
+
85
+ def persist_hwm_as_trace(chat_id:)
86
+ hwm = get_extended_hwm(chat_id: chat_id)
87
+ return unless hwm
88
+
89
+ memory_runner.store_trace(
90
+ type: :procedural,
91
+ content_payload: ::JSON.dump({ chat_id: chat_id }.merge(hwm)),
92
+ domain_tags: ['teams', 'hwm', "chat:#{chat_id}"],
93
+ confidence: 1.0,
94
+ origin: :direct_experience
95
+ )
96
+ end
97
+
98
+ def restore_hwm_from_traces
99
+ traces = memory_runner.retrieve_by_domain(domain_tag: 'teams', min_strength: 0.0, limit: 500)
100
+ return unless traces.is_a?(Array)
101
+
102
+ traces.select { |t| t[:trace_type] == :procedural && t[:domain_tags]&.include?('hwm') }.each do |trace|
103
+ data = ::JSON.parse(trace[:content_payload], symbolize_names: true)
104
+ next unless data[:chat_id]
105
+
106
+ set_extended_hwm(chat_id: data[:chat_id], last_message_at: data[:last_message_at],
107
+ last_ingested_at: data[:last_ingested_at], message_count: data[:message_count] || 0)
108
+ end
109
+ rescue StandardError => e
110
+ log_warn("Failed to restore HWM from traces: #{e.message}") if respond_to?(:log_warn, true)
111
+ end
112
+
113
+ def memory_runner
114
+ @memory_runner ||= begin
115
+ runner = Object.new
116
+ runner.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
117
+ runner
118
+ end
119
+ end
120
+
48
121
  private
49
122
 
50
123
  def cache_available?
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Helpers
7
+ module PermissionGuard
8
+ BACKOFF_SCHEDULE = [60, 300, 1800, 7200, 28_800].freeze
9
+
10
+ def permission_denied?(endpoint)
11
+ denial = permission_denials[endpoint]
12
+ return false unless denial
13
+
14
+ Time.now.utc < denial[:retry_after]
15
+ end
16
+
17
+ def record_denial(endpoint, error_message)
18
+ denial = permission_denials[endpoint] || { count: 0 }
19
+ denial[:count] += 1
20
+ backoff = BACKOFF_SCHEDULE.fetch(denial[:count] - 1, BACKOFF_SCHEDULE.last)
21
+ denial[:retry_after] = Time.now.utc + backoff
22
+ permission_denials[endpoint] = denial
23
+ log_warn("Graph API permission denied for #{endpoint}: #{error_message}. " \
24
+ "Retry in #{backoff}s (attempt #{denial[:count]})")
25
+ end
26
+
27
+ def denial_info(endpoint)
28
+ permission_denials[endpoint]
29
+ end
30
+
31
+ def reset_denials!
32
+ @permission_denials = {}
33
+ end
34
+
35
+ def guarded_request(endpoint)
36
+ return { skipped: true, endpoint: endpoint, reason: :permission_denied } if permission_denied?(endpoint)
37
+
38
+ result = yield
39
+ if result.is_a?(Hash) && result[:status] == 403
40
+ msg = result.dig(:result, 'error', 'message') || 'Unknown'
41
+ record_denial(endpoint, msg)
42
+ end
43
+ result
44
+ end
45
+
46
+ private
47
+
48
+ def permission_denials
49
+ @permission_denials ||= {}
50
+ end
51
+
52
+ def log_warn(msg)
53
+ if defined?(Legion::Logging)
54
+ Legion::Logging.warn(msg)
55
+ else
56
+ warn(msg)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Helpers
7
+ module TransformDefinitions
8
+ module_function
9
+
10
+ def conversation_extract
11
+ {
12
+ name: 'teams.conversation.extract',
13
+ structured: true,
14
+ prompt: 'Analyze this conversation between two people. Extract their communication ' \
15
+ 'style, recurring topics, the nature of their working relationship, and any ' \
16
+ 'pending action items. Be concise.',
17
+ schema: {
18
+ type: :object,
19
+ properties: {
20
+ communication_style: { type: :string, description: 'How this person communicates (formal, casual, terse, detailed, etc.)' },
21
+ topics: { type: :array, items: { type: :string }, description: 'Recurring discussion topics' },
22
+ relationship_context: { type: :string, description: 'Nature of working relationship (manager, peer, cross-team, mentor, etc.)' },
23
+ action_items: { type: :array, items: { type: :string }, description: 'Pending tasks or follow-ups' }
24
+ },
25
+ required: %i[communication_style topics relationship_context action_items]
26
+ },
27
+ engine_options: { max_retries: 2 }
28
+ }
29
+ end
30
+
31
+ def person_summary
32
+ {
33
+ name: 'teams.person.summary',
34
+ structured: true,
35
+ prompt: 'Given this person profile data from Microsoft Teams, write a brief summary ' \
36
+ 'of who this person is and their role. Keep it factual and concise.',
37
+ schema: {
38
+ type: :object,
39
+ properties: {
40
+ summary: { type: :string, description: 'One-sentence summary of this person' },
41
+ role_category: { type: :string, description: 'Role category: engineering, management, design, product, support, other' }
42
+ },
43
+ required: %i[summary role_category]
44
+ },
45
+ engine_options: { max_retries: 1 }
46
+ }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/microsoft_teams/helpers/client'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module MicrosoftTeams
8
+ module Runners
9
+ module People
10
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
11
+
12
+ def get_profile(user_id: 'me', **)
13
+ response = graph_connection(**).get(user_path(user_id).to_s)
14
+ { result: response.body }
15
+ rescue StandardError => e
16
+ { error: e.message }
17
+ end
18
+
19
+ def list_people(user_id: 'me', top: 25, **)
20
+ params = { '$top' => top }
21
+ response = graph_connection(**).get("#{user_path(user_id)}/people", params)
22
+ { result: response.body }
23
+ rescue StandardError => e
24
+ { error: e.message }
25
+ end
26
+
27
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
28
+ Legion::Extensions::Helpers.const_defined?(:Lex)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'legion/extensions/microsoft_teams/helpers/client'
5
+ require 'legion/extensions/microsoft_teams/helpers/permission_guard'
6
+ require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
7
+ require 'legion/extensions/microsoft_teams/helpers/transform_definitions'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module MicrosoftTeams
12
+ module Runners
13
+ module ProfileIngest
14
+ include Helpers::Client
15
+ include Helpers::PermissionGuard
16
+ include Helpers::HighWaterMark
17
+
18
+ def full_ingest(token:, top_people: 10, message_depth: 50, **)
19
+ self_result = ingest_self(token: token)
20
+ people_result = ingest_people(token: token, top: 25)
21
+ people = people_result[:skipped] ? [] : (people_result[:people] || [])
22
+ conv_result = ingest_conversations(token: token, people: people,
23
+ top_people: top_people, message_depth: message_depth)
24
+ teams_result = ingest_teams_and_meetings(token: token)
25
+
26
+ { self: self_result, people: people_result, conversations: conv_result, teams: teams_result }
27
+ end
28
+
29
+ def ingest_self(token:, **)
30
+ conn = graph_connection(token: token)
31
+ profile = conn.get('me').body
32
+
33
+ memory_runner.store_trace(
34
+ type: :identity,
35
+ content_payload: ::JSON.dump(profile),
36
+ domain_tags: %w[teams self owner],
37
+ confidence: 1.0,
38
+ origin: :direct_experience
39
+ )
40
+
41
+ presence = begin
42
+ conn.get('me/presence').body
43
+ rescue StandardError
44
+ {}
45
+ end
46
+ unless presence.empty?
47
+ memory_runner.store_trace(
48
+ type: :sensory,
49
+ content_payload: ::JSON.dump(presence),
50
+ domain_tags: %w[teams presence self],
51
+ confidence: 0.8,
52
+ origin: :direct_experience
53
+ )
54
+ end
55
+
56
+ { profile: profile, presence: presence }
57
+ rescue StandardError => e
58
+ { error: e.message }
59
+ end
60
+
61
+ def ingest_people(token:, top: 25, **)
62
+ return { skipped: true, reason: :permission_denied } if permission_denied?('/me/people')
63
+
64
+ conn = graph_connection(token: token)
65
+ resp = conn.get('me/people', { '$top' => top })
66
+
67
+ if resp.respond_to?(:status) && resp.status == 403
68
+ record_denial('/me/people', resp.body.dig('error', 'message') || 'Forbidden')
69
+ return { skipped: true, reason: :permission_denied }
70
+ end
71
+
72
+ people = (resp.body || {}).fetch('value', [])
73
+ people.sort_by! { |p| -(p.dig('scoredEmailAddresses', 0, 'relevanceScore') || 0) }
74
+
75
+ people.each do |person|
76
+ name = person['displayName'] || 'Unknown'
77
+ memory_runner.store_trace(
78
+ type: :semantic,
79
+ content_payload: ::JSON.dump(person.slice('displayName', 'jobTitle', 'department',
80
+ 'officeLocation', 'scoredEmailAddresses')),
81
+ domain_tags: ['teams', 'peer', "peer:#{name}"],
82
+ confidence: 0.7,
83
+ origin: :direct_experience
84
+ )
85
+ end
86
+
87
+ { people: people, count: people.length }
88
+ rescue StandardError => e
89
+ { error: e.message, skipped: false }
90
+ end
91
+
92
+ def ingest_conversations(token:, people:, top_people: 10, message_depth: 50, **)
93
+ return { ingested: 0 } if people.empty?
94
+
95
+ conn = graph_connection(token: token)
96
+ chats_resp = conn.get('me/chats', { '$top' => 50 })
97
+ chats = (chats_resp.body || {}).fetch('value', [])
98
+ ingested = 0
99
+
100
+ people.first(top_people).each do |person|
101
+ email = person.dig('scoredEmailAddresses', 0, 'address')
102
+ next unless email
103
+
104
+ chat = find_chat_for_person(chats: chats, email: email, conn: conn)
105
+ next unless chat
106
+
107
+ messages = fetch_new_messages(conn: conn, chat_id: chat['id'], depth: message_depth)
108
+ next if messages.empty?
109
+
110
+ extraction = extract_conversation(messages: messages, peer_name: person['displayName'])
111
+ next unless extraction
112
+
113
+ memory_runner.store_trace(
114
+ type: :episodic,
115
+ content_payload: ::JSON.dump({
116
+ peer: person['displayName'], chat_id: chat['id'],
117
+ summary: extraction, last_active: messages.first&.dig('createdDateTime')
118
+ }),
119
+ domain_tags: ['teams', 'conversation', "peer:#{person['displayName']}"],
120
+ confidence: 0.6,
121
+ origin: :direct_experience
122
+ )
123
+
124
+ update_extended_hwm(chat_id: chat['id'],
125
+ last_message_at: messages.map { |m| m['createdDateTime'] }.max,
126
+ new_message_count: messages.length, ingested: true)
127
+ persist_hwm_as_trace(chat_id: chat['id'])
128
+ ingested += 1
129
+ end
130
+
131
+ { ingested: ingested }
132
+ rescue StandardError => e
133
+ { error: e.message, ingested: ingested || 0 }
134
+ end
135
+
136
+ def ingest_teams_and_meetings(token:, **)
137
+ conn = graph_connection(token: token)
138
+ teams_count = 0
139
+
140
+ unless permission_denied?('/me/joinedTeams')
141
+ teams_resp = conn.get('me/joinedTeams')
142
+ teams = (teams_resp.body || {}).fetch('value', [])
143
+
144
+ teams.each do |team|
145
+ members_resp = conn.get("teams/#{team['id']}/members")
146
+ members = (members_resp.body || {}).fetch('value', [])
147
+ memory_runner.store_trace(
148
+ type: :semantic,
149
+ content_payload: ::JSON.dump({ team: team['displayName'], member_count: members.length,
150
+ members: members.map { |m| m['displayName'] } }),
151
+ domain_tags: ['teams', 'org', "team:#{team['displayName']}"],
152
+ confidence: 0.8,
153
+ origin: :direct_experience
154
+ )
155
+ teams_count += 1
156
+ end
157
+ end
158
+
159
+ meetings_count = 0
160
+ unless permission_denied?('/me/onlineMeetings')
161
+ meetings_resp = conn.get('me/onlineMeetings')
162
+ meetings = (meetings_resp.body || {}).fetch('value', [])
163
+ meetings.each do |meeting|
164
+ memory_runner.store_trace(
165
+ type: :episodic,
166
+ content_payload: ::JSON.dump(meeting.slice('subject', 'startDateTime', 'endDateTime',
167
+ 'participants')),
168
+ domain_tags: %w[teams meeting],
169
+ confidence: 0.5,
170
+ origin: :direct_experience
171
+ )
172
+ meetings_count += 1
173
+ end
174
+ end
175
+
176
+ { teams: teams_count, meetings: meetings_count }
177
+ rescue StandardError => e
178
+ { error: e.message }
179
+ end
180
+
181
+ def incremental_sync(token:, top_people: 10, message_depth: 50, **)
182
+ ingest_self(token: token)
183
+ people_result = ingest_people(token: token, top: 25)
184
+ people = people_result[:skipped] ? [] : (people_result[:people] || [])
185
+
186
+ return { refreshed: true, conversations: 0 } if people.empty?
187
+
188
+ ingest_conversations(token: token, people: people,
189
+ top_people: top_people, message_depth: message_depth)
190
+ end
191
+
192
+ private
193
+
194
+ def find_chat_for_person(chats:, email:, conn:)
195
+ chats.select { |c| c['chatType'] == 'oneOnOne' }.find do |chat|
196
+ members_resp = conn.get("chats/#{chat['id']}/members")
197
+ members = (members_resp.body || {}).fetch('value', [])
198
+ members.any? { |m| m['email']&.downcase == email.downcase }
199
+ end
200
+ rescue StandardError
201
+ nil
202
+ end
203
+
204
+ def fetch_new_messages(conn:, chat_id:, depth: 50)
205
+ hwm = get_extended_hwm(chat_id: chat_id)
206
+ params = { '$top' => depth, '$orderby' => 'createdDateTime desc' }
207
+ params['$filter'] = "createdDateTime gt #{hwm[:last_message_at]}" if hwm&.dig(:last_message_at)
208
+
209
+ resp = conn.get("chats/#{chat_id}/messages", params)
210
+ (resp.body || {}).fetch('value', [])
211
+ rescue StandardError
212
+ []
213
+ end
214
+
215
+ def extract_conversation(messages:, peer_name:)
216
+ return nil if messages.empty?
217
+
218
+ definition = Helpers::TransformDefinitions.conversation_extract
219
+ text = messages.map do |m|
220
+ from = m.dig('from', 'user', 'displayName') || 'Unknown'
221
+ "#{from}: #{m['body']&.dig('content') || ''}"
222
+ end.join("\n")
223
+
224
+ if defined?(Legion::Extensions::Transformer::Client)
225
+ client = Legion::Extensions::Transformer::Client.new
226
+ result = client.transform(text: text, **definition)
227
+ result[:result] || result[:error] ? nil : result
228
+ elsif defined?(Legion::LLM)
229
+ Legion::LLM.ask(prompt: "#{definition[:prompt]}\n\nConversation with #{peer_name}:\n#{text}")
230
+ end
231
+ rescue StandardError
232
+ nil
233
+ end
234
+
235
+ def memory_runner
236
+ @memory_runner ||= begin
237
+ runner = Object.new
238
+ runner.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
239
+ runner
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.5.6'
6
+ VERSION = '0.6.0'
7
7
  end
8
8
  end
9
9
  end
@@ -16,6 +16,8 @@ require 'legion/extensions/microsoft_teams/runners/meetings'
16
16
  require 'legion/extensions/microsoft_teams/runners/transcripts'
17
17
  require 'legion/extensions/microsoft_teams/runners/local_cache'
18
18
  require 'legion/extensions/microsoft_teams/runners/cache_ingest'
19
+ require 'legion/extensions/microsoft_teams/runners/people'
20
+ require 'legion/extensions/microsoft_teams/runners/profile_ingest'
19
21
 
20
22
  # Helpers (bot)
21
23
  require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
@@ -25,6 +27,8 @@ require 'legion/extensions/microsoft_teams/helpers/subscription_registry'
25
27
  require 'legion/extensions/microsoft_teams/helpers/token_cache'
26
28
  require 'legion/extensions/microsoft_teams/helpers/callback_server'
27
29
  require 'legion/extensions/microsoft_teams/helpers/browser_auth'
30
+ require 'legion/extensions/microsoft_teams/helpers/permission_guard'
31
+ require 'legion/extensions/microsoft_teams/helpers/transform_definitions'
28
32
 
29
33
  # Transport
30
34
  if defined?(Legion::Transport)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-microsoft_teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.6
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -79,18 +79,23 @@ files:
79
79
  - lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb
80
80
  - lib/legion/extensions/microsoft_teams/actors/cache_sync.rb
81
81
  - lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb
82
+ - lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb
82
83
  - lib/legion/extensions/microsoft_teams/actors/message_processor.rb
83
84
  - lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb
85
+ - lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb
84
86
  - lib/legion/extensions/microsoft_teams/actors/token_refresher.rb
87
+ - lib/legion/extensions/microsoft_teams/cli/auth.rb
85
88
  - lib/legion/extensions/microsoft_teams/client.rb
86
89
  - lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb
87
90
  - lib/legion/extensions/microsoft_teams/helpers/callback_server.rb
88
91
  - lib/legion/extensions/microsoft_teams/helpers/client.rb
89
92
  - lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb
93
+ - lib/legion/extensions/microsoft_teams/helpers/permission_guard.rb
90
94
  - lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb
91
95
  - lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
92
96
  - lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
93
97
  - lib/legion/extensions/microsoft_teams/helpers/token_cache.rb
98
+ - lib/legion/extensions/microsoft_teams/helpers/transform_definitions.rb
94
99
  - lib/legion/extensions/microsoft_teams/hooks/auth.rb
95
100
  - lib/legion/extensions/microsoft_teams/local_cache/extractor.rb
96
101
  - lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb
@@ -105,7 +110,9 @@ files:
105
110
  - lib/legion/extensions/microsoft_teams/runners/local_cache.rb
106
111
  - lib/legion/extensions/microsoft_teams/runners/meetings.rb
107
112
  - lib/legion/extensions/microsoft_teams/runners/messages.rb
113
+ - lib/legion/extensions/microsoft_teams/runners/people.rb
108
114
  - lib/legion/extensions/microsoft_teams/runners/presence.rb
115
+ - lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb
109
116
  - lib/legion/extensions/microsoft_teams/runners/subscriptions.rb
110
117
  - lib/legion/extensions/microsoft_teams/runners/teams.rb
111
118
  - lib/legion/extensions/microsoft_teams/runners/transcripts.rb