@indexnetwork/protocol 14.3.2-rc.477.1 → 16.1.1-rc.478.1

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/IMPLEMENTATION.md +50 -52
  3. package/STABILITY.md +3 -3
  4. package/dist/chat/chat.agent.js +2 -2
  5. package/dist/chat/chat.graph.d.ts +41 -1
  6. package/dist/chat/chat.graph.js +108 -127
  7. package/dist/discovery/hyde.frame.d.ts +4 -4
  8. package/dist/discovery/hyde.graph.d.ts +71 -12
  9. package/dist/discovery/hyde.graph.js +337 -335
  10. package/dist/discovery/lens.inferrer.d.ts +6 -6
  11. package/dist/enrichment/enrichment.graph.d.ts +127 -9
  12. package/dist/enrichment/enrichment.graph.js +633 -655
  13. package/dist/enrichment/enrichment.state.d.ts +1 -1
  14. package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
  15. package/dist/enrichment/enrichment.tools.context-read.js +353 -0
  16. package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
  17. package/dist/enrichment/enrichment.tools.context-write.js +417 -0
  18. package/dist/enrichment/enrichment.tools.d.ts +11 -1
  19. package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
  20. package/dist/enrichment/enrichment.tools.helpers.js +234 -0
  21. package/dist/enrichment/enrichment.tools.js +16 -975
  22. package/dist/index.d.ts +27 -48
  23. package/dist/index.js +17 -27
  24. package/dist/intents/application/intent.graph.d.ts +71 -67
  25. package/dist/intents/application/intent.graph.execute.d.ts +59 -0
  26. package/dist/intents/application/intent.graph.execute.js +301 -0
  27. package/dist/intents/application/intent.graph.infer.d.ts +44 -0
  28. package/dist/intents/application/intent.graph.infer.js +97 -0
  29. package/dist/intents/application/intent.graph.js +96 -888
  30. package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
  31. package/dist/intents/application/intent.graph.reconcile.js +274 -0
  32. package/dist/intents/application/intent.graph.shared.d.ts +70 -0
  33. package/dist/intents/application/intent.graph.shared.js +153 -0
  34. package/dist/intents/domain/intent.state.d.ts +1 -1
  35. package/dist/maintenance/maintenance.graph.d.ts +66 -3
  36. package/dist/maintenance/maintenance.graph.js +155 -156
  37. package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
  38. package/dist/mcp/mcp.authorization-policy.js +14 -34
  39. package/dist/mcp/mcp.server.d.ts +3 -3
  40. package/dist/mcp/mcp.server.js +12 -16
  41. package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
  42. package/dist/negotiations/application/negotiation.candidates.js +162 -0
  43. package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
  44. package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
  45. package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
  46. package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
  47. package/dist/negotiations/application/negotiation.graph.init.js +227 -0
  48. package/dist/negotiations/application/negotiation.graph.js +69 -1388
  49. package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
  50. package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
  51. package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
  52. package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
  53. package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
  54. package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
  55. package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
  56. package/dist/negotiations/domain/negotiation.state.js +0 -1
  57. package/dist/networks/application/indexer.graph.d.ts +165 -7
  58. package/dist/networks/application/indexer.graph.js +339 -388
  59. package/dist/networks/application/indexer.state.d.ts +1 -1
  60. package/dist/networks/application/membership.graph.d.ts +83 -5
  61. package/dist/networks/application/membership.graph.js +177 -207
  62. package/dist/networks/application/network.graph.d.ts +148 -5
  63. package/dist/networks/application/network.graph.js +249 -278
  64. package/dist/networks/domain/membership.state.d.ts +1 -1
  65. package/dist/networks/domain/network.state.d.ts +1 -1
  66. package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
  67. package/dist/opportunities/application/delivery-card.cache.js +2 -2
  68. package/dist/opportunities/application/index.d.ts +2 -2
  69. package/dist/opportunities/application/index.js +2 -2
  70. package/dist/opportunities/application/opportunity.evaluator.js +1 -1
  71. package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
  72. package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
  73. package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
  74. package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
  75. package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
  76. package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
  77. package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
  78. package/dist/opportunities/application/opportunity.graph.js +120 -3690
  79. package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
  80. package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
  81. package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
  82. package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
  83. package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
  84. package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
  85. package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
  86. package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
  87. package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
  88. package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
  89. package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
  90. package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
  91. package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
  92. package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
  93. package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
  94. package/dist/opportunities/application/opportunity.tools.js +28 -711
  95. package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
  96. package/dist/opportunities/application/opportunity.tools.list.js +493 -0
  97. package/dist/opportunities/domain/index.d.ts +3 -3
  98. package/dist/opportunities/domain/index.js +3 -3
  99. package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
  100. package/dist/opportunities/index.d.ts +6 -6
  101. package/dist/opportunities/index.js +4 -4
  102. package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
  103. package/dist/opportunities/radar/radar.graph.d.ts +57 -13
  104. package/dist/opportunities/radar/radar.graph.js +470 -471
  105. package/dist/premises/premise.graph.d.ts +88 -20
  106. package/dist/premises/premise.graph.js +207 -218
  107. package/dist/questions/domain/question.schema.d.ts +40 -40
  108. package/dist/shared/agent/tool.helpers.d.ts +21 -0
  109. package/dist/shared/agent/tool.registry.d.ts +4 -3
  110. package/dist/shared/agent/tool.registry.js +1 -1
  111. package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
  112. package/dist/shared/interfaces/database.capabilities.js +7 -0
  113. package/dist/shared/interfaces/database.entities.d.ts +533 -0
  114. package/dist/shared/interfaces/database.entities.js +10 -0
  115. package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
  116. package/dist/shared/interfaces/database.identity-queries.js +4 -0
  117. package/dist/shared/interfaces/database.interface.d.ts +15 -2277
  118. package/dist/shared/interfaces/database.interface.js +8 -0
  119. package/dist/shared/interfaces/database.member-queries.d.ts +228 -0
  120. package/dist/shared/interfaces/database.member-queries.js +4 -0
  121. package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
  122. package/dist/shared/interfaces/database.negotiation.js +26 -0
  123. package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
  124. package/dist/shared/interfaces/database.network-queries.js +4 -0
  125. package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
  126. package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
  127. package/dist/shared/interfaces/database.port.d.ts +322 -0
  128. package/dist/shared/interfaces/database.port.js +29 -0
  129. package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
  130. package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
  131. package/package.json +2 -15
  132. package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
  133. package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
  134. package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
  135. package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
  136. package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
  137. package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
  138. package/dist/opportunities/domain/opportunity.presentation.js +0 -516
  139. package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
  140. package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
@@ -35,718 +35,696 @@ function isMeaningfulProfileInput(input) {
35
35
  return false;
36
36
  return true;
37
37
  }
38
- /**
39
- * Factory class to build and compile the Profile Generation Graph.
40
- *
41
- * Flow:
42
- * 1. check_state - Detect whether profile needs generation
43
- * 2. Conditional routing based on operation mode and missing components:
44
- * - Query mode: Return immediately (fast path)
45
- * - Write mode: Generate only what's needed
46
- * 3. Profile generation (if needed)
47
- * 4. Save profile to DB
48
- *
49
- * Key Features:
50
- * - Read/Write separation (query vs write)
51
- * - Conditional generation (skip generation if profile already exists)
52
- */
53
38
  export class EnrichmentGraphFactory {
54
39
  constructor(database, scraper, enricher, premiseGraph) {
55
- this.database = database;
56
- this.scraper = scraper;
57
- this.enricher = enricher;
58
- this.premiseGraph = premiseGraph;
40
+ this.deps = { database, scraper, enricher, premiseGraph, premiseDecomposer: new PremiseDecomposer() };
59
41
  }
60
42
  createGraph() {
61
- const premiseDecomposer = new PremiseDecomposer();
43
+ const deps = this.deps;
62
44
  // ─────────────────────────────────────────────────────────
63
- // NODE: Check State
64
- // Loads existing profile from DB and detects what needs generation:
65
- // - Profile missing
66
- // - User information insufficient for scraping
45
+ // GRAPH ASSEMBLY
46
+ // Conditional flow based on operation mode and detected needs
67
47
  // ─────────────────────────────────────────────────────────
68
- const checkStateNode = async (state) => {
69
- return timed("ProfileGraph.checkState", async () => {
70
- if (!state.userId) {
71
- logger.error("Missing userId");
48
+ const workflow = new StateGraph(EnrichmentGraphState)
49
+ // Add all nodes
50
+ .addNode("check_state", (state) => checkStateNode(state, deps))
51
+ .addNode("scrape", (state) => scrapeNode(state, deps))
52
+ .addNode("decompose_premises", (state) => decomposePremisesNode(state, deps))
53
+ .addNode("auto_generate", (state) => autoGenerateNode(state, deps))
54
+ // Start with state check
55
+ .addEdge(START, "check_state")
56
+ // Conditional routing from check_state
57
+ .addConditionalEdges("check_state", (state) => checkStateCondition(state, deps), {
58
+ auto_generate: "auto_generate", // Generate mode -> Chat API enrichment
59
+ decompose_premises: "decompose_premises", // Write mode + input + premise graph -> decompose
60
+ scrape: "scrape", // Need premises, no input -> scrape first
61
+ [END]: END // Query mode, no premise graph, or everything exists
62
+ })
63
+ // Decompose premises creates premises as a side effect, then ends.
64
+ // user_context regeneration is handled downstream by premise lifecycle events.
65
+ .addEdge("decompose_premises", END)
66
+ // Auto-generate routes to premise decomposition (when premise graph
67
+ // available + enrichment produced input), otherwise ends.
68
+ .addConditionalEdges("auto_generate", (state) => {
69
+ if (state.input && deps.premiseGraph) {
70
+ logger.verbose("Enrichment produced input — routing to premise decomposition");
71
+ return "decompose_premises";
72
+ }
73
+ logger.verbose("Enrichment ended without usable input (ghost soft-deleted, error, or no premise graph) — done");
74
+ return END;
75
+ }, {
76
+ decompose_premises: "decompose_premises",
77
+ [END]: END,
78
+ })
79
+ // Scrape -> decompose_premises (when premise graph available), else ends.
80
+ .addConditionalEdges("scrape", (_state) => {
81
+ if (deps.premiseGraph)
82
+ return "decompose_premises";
83
+ return END;
84
+ }, {
85
+ decompose_premises: "decompose_premises",
86
+ [END]: END,
87
+ });
88
+ logger.verbose("Graph built successfully");
89
+ return workflow.compile();
90
+ }
91
+ }
92
+ // ─────────────────────────────────────────────────────────
93
+ // NODE: Check State
94
+ // Loads existing profile from DB and detects what needs generation:
95
+ // - Profile missing
96
+ // - User information insufficient for scraping
97
+ // ─────────────────────────────────────────────────────────
98
+ export async function checkStateNode(state, deps) {
99
+ return timed("ProfileGraph.checkState", async () => {
100
+ if (!state.userId) {
101
+ logger.error("Missing userId");
102
+ return {
103
+ error: "userId is required"
104
+ };
105
+ }
106
+ logger.verbose("Checking profile state...", {
107
+ userId: state.userId,
108
+ operationMode: state.operationMode,
109
+ forceUpdate: state.forceUpdate
110
+ });
111
+ try {
112
+ const profile = await deps.database.getProfile(state.userId);
113
+ // "Has a profile" now means the user has been enriched into ACTIVE premises
114
+ // (the user_profiles replacement). `getProfile` returns a users-sourced row
115
+ // for every existing user, so its presence no longer signals enrichment --
116
+ // the premise graph is the source of truth for whether generation has run.
117
+ const hasBeenEnriched = (await deps.database.getPremisesForUser(state.userId, 'ACTIVE')).length > 0;
118
+ // Query mode: Just return the profile (fast path)
119
+ if (state.operationMode === 'query') {
120
+ logger.verbose("🚀 Query mode - returning existing profile (fast path)", {
121
+ hasProfile: hasBeenEnriched
122
+ });
123
+ const profileWithId = hasBeenEnriched ? await deps.database.getProfileByUserId(state.userId) : null;
124
+ return {
125
+ profile: hasBeenEnriched ? (profile || undefined) : undefined,
126
+ readResult: hasBeenEnriched
127
+ ? {
128
+ hasProfile: true,
129
+ // Thin identity only. The structured skills/interests attributes are
130
+ // retired (user_profiles removal, WS6); the rich identity text now
131
+ // comes from the global user_context, injected by read_user_contexts.
132
+ profile: {
133
+ id: profileWithId?.id,
134
+ name: profile?.identity?.name,
135
+ bio: profile?.identity?.bio,
136
+ location: profile?.identity?.location,
137
+ },
138
+ }
139
+ : {
140
+ hasProfile: false,
141
+ message: "You don't have a profile yet. Would you like to create one? You can share your LinkedIn, GitHub, or X/Twitter profile, or just tell me about yourself.",
142
+ },
143
+ };
144
+ }
145
+ // Write mode: Detect what needs generation
146
+ // Treat confirmation-only input (e.g. "Yes") as no input so we ask for info / use scraper
147
+ const hasMeaningfulInput = !!state.input && isMeaningfulProfileInput(state.input);
148
+ const needsProfileGeneration = !hasBeenEnriched || (state.forceUpdate && hasMeaningfulInput);
149
+ // Check if we need to scrape (profile generation needed but no meaningful input provided)
150
+ const willNeedScraping = needsProfileGeneration && !hasMeaningfulInput;
151
+ // If we need to scrape, check if we have sufficient user information
152
+ let needsUserInfo = false;
153
+ const missingUserInfo = [];
154
+ if (willNeedScraping) {
155
+ logger.verbose("Will need scraping - checking user information...");
156
+ const user = await deps.database.getUser(state.userId);
157
+ if (!user) {
158
+ logger.error("User not found", { userId: state.userId });
72
159
  return {
73
- error: "userId is required"
160
+ error: `User not found: ${state.userId}`
74
161
  };
75
162
  }
76
- logger.verbose("Checking profile state...", {
77
- userId: state.userId,
78
- operationMode: state.operationMode,
79
- forceUpdate: state.forceUpdate
80
- });
81
- try {
82
- const profile = await this.database.getProfile(state.userId);
83
- // "Has a profile" now means the user has been enriched into ACTIVE premises
84
- // (the user_profiles replacement). `getProfile` returns a users-sourced row
85
- // for every existing user, so its presence no longer signals enrichment --
86
- // the premise graph is the source of truth for whether generation has run.
87
- const hasBeenEnriched = (await this.database.getPremisesForUser(state.userId, 'ACTIVE')).length > 0;
88
- // Query mode: Just return the profile (fast path)
89
- if (state.operationMode === 'query') {
90
- logger.verbose("🚀 Query mode - returning existing profile (fast path)", {
91
- hasProfile: hasBeenEnriched
92
- });
93
- const profileWithId = hasBeenEnriched ? await this.database.getProfileByUserId(state.userId) : null;
94
- return {
95
- profile: hasBeenEnriched ? (profile || undefined) : undefined,
96
- readResult: hasBeenEnriched
97
- ? {
98
- hasProfile: true,
99
- // Thin identity only. The structured skills/interests attributes are
100
- // retired (user_profiles removal, WS6); the rich identity text now
101
- // comes from the global user_context, injected by read_user_contexts.
102
- profile: {
103
- id: profileWithId?.id,
104
- name: profile?.identity?.name,
105
- bio: profile?.identity?.bio,
106
- location: profile?.identity?.location,
107
- },
108
- }
109
- : {
110
- hasProfile: false,
111
- message: "You don't have a profile yet. Would you like to create one? You can share your LinkedIn, GitHub, or X/Twitter profile, or just tell me about yourself.",
112
- },
113
- };
163
+ // Check what information we have from the user table (schema: users)
164
+ // Required fields: email, name (always present)
165
+ // Optional fields: intro, avatar, location, socials
166
+ const socials = await deps.database.getUserSocials(state.userId);
167
+ const hasSocials = socials.length > 0;
168
+ // Check if name is a full name (not just email username)
169
+ // For scraping to work well, we need first + last name
170
+ const hasMeaningfulName = user.name &&
171
+ user.name.trim() !== '' &&
172
+ !user.name.includes('@') &&
173
+ user.name.split(/\s+/).filter(Boolean).length >= 2;
174
+ const hasLocation = !!(user.location && user.location.trim() !== '');
175
+ // Minimum requirement for accurate scraping:
176
+ // - At least ONE social link (preferred - most reliable for finding the right person)
177
+ // - OR a full name (first + last) - less reliable but workable
178
+ // Location helps disambiguate but is not required
179
+ const hasMinimumInfo = hasSocials || hasMeaningfulName;
180
+ if (!hasMinimumInfo) {
181
+ needsUserInfo = true;
182
+ // Build precise list of what's missing and would help
183
+ if (!hasSocials) {
184
+ missingUserInfo.push('social_urls');
114
185
  }
115
- // Write mode: Detect what needs generation
116
- // Treat confirmation-only input (e.g. "Yes") as no input so we ask for info / use scraper
117
- const hasMeaningfulInput = !!state.input && isMeaningfulProfileInput(state.input);
118
- const needsProfileGeneration = !hasBeenEnriched || (state.forceUpdate && hasMeaningfulInput);
119
- // Check if we need to scrape (profile generation needed but no meaningful input provided)
120
- const willNeedScraping = needsProfileGeneration && !hasMeaningfulInput;
121
- // If we need to scrape, check if we have sufficient user information
122
- let needsUserInfo = false;
123
- const missingUserInfo = [];
124
- if (willNeedScraping) {
125
- logger.verbose("Will need scraping - checking user information...");
126
- const user = await this.database.getUser(state.userId);
127
- if (!user) {
128
- logger.error("User not found", { userId: state.userId });
129
- return {
130
- error: `User not found: ${state.userId}`
131
- };
132
- }
133
- // Check what information we have from the user table (schema: users)
134
- // Required fields: email, name (always present)
135
- // Optional fields: intro, avatar, location, socials
136
- const socials = await this.database.getUserSocials(state.userId);
137
- const hasSocials = socials.length > 0;
138
- // Check if name is a full name (not just email username)
139
- // For scraping to work well, we need first + last name
140
- const hasMeaningfulName = user.name &&
141
- user.name.trim() !== '' &&
142
- !user.name.includes('@') &&
143
- user.name.split(/\s+/).filter(Boolean).length >= 2;
144
- const hasLocation = !!(user.location && user.location.trim() !== '');
145
- // Minimum requirement for accurate scraping:
146
- // - At least ONE social link (preferred - most reliable for finding the right person)
147
- // - OR a full name (first + last) - less reliable but workable
148
- // Location helps disambiguate but is not required
149
- const hasMinimumInfo = hasSocials || hasMeaningfulName;
150
- if (!hasMinimumInfo) {
151
- needsUserInfo = true;
152
- // Build precise list of what's missing and would help
153
- if (!hasSocials) {
154
- missingUserInfo.push('social_urls');
155
- }
156
- if (!hasMeaningfulName) {
157
- missingUserInfo.push('full_name');
158
- }
159
- if (!hasLocation) {
160
- missingUserInfo.push('location'); // Nice to have
161
- }
162
- logger.verbose("⚠️ Insufficient user information for scraping", {
163
- hasSocials,
164
- hasMeaningfulName,
165
- hasLocation,
166
- currentName: user.name,
167
- missingUserInfo
168
- });
169
- }
170
- else {
171
- logger.verbose("✅ Sufficient user information for scraping", {
172
- hasSocials,
173
- hasMeaningfulName,
174
- hasLocation,
175
- willProceedWith: hasSocials ? 'social links' : 'full name'
176
- });
177
- }
186
+ if (!hasMeaningfulName) {
187
+ missingUserInfo.push('full_name');
178
188
  }
179
- logger.verbose("📊 State detection complete", {
180
- hasProfile: hasBeenEnriched,
181
- needsProfileGeneration,
182
- needsUserInfo,
183
- missingUserInfo,
184
- forceUpdate: state.forceUpdate,
185
- hasInput: !!state.input,
186
- hasMeaningfulInput,
187
- });
188
- return {
189
- // Only treat the (users-sourced) profile as existing state when the user has
190
- // actually been enriched; un-enriched users keep `undefined` so the generate
191
- // path runs from scratch rather than merging into an empty users row.
192
- profile: hasBeenEnriched ? (profile || undefined) : undefined,
193
- needsProfileGeneration,
194
- needsUserInfo,
189
+ if (!hasLocation) {
190
+ missingUserInfo.push('location'); // Nice to have
191
+ }
192
+ logger.verbose("⚠️ Insufficient user information for scraping", {
193
+ hasSocials,
194
+ hasMeaningfulName,
195
+ hasLocation,
196
+ currentName: user.name,
195
197
  missingUserInfo
196
- };
198
+ });
197
199
  }
198
- catch (error) {
199
- logger.error("Failed to load profile", {
200
- error: error instanceof Error ? error.message : String(error)
200
+ else {
201
+ logger.verbose(" Sufficient user information for scraping", {
202
+ hasSocials,
203
+ hasMeaningfulName,
204
+ hasLocation,
205
+ willProceedWith: hasSocials ? 'social links' : 'full name'
201
206
  });
202
- return {
203
- profile: undefined,
204
- error: `Failed to load profile from database: ${error instanceof Error ? error.message : String(error)}`
205
- };
206
207
  }
208
+ }
209
+ logger.verbose("📊 State detection complete", {
210
+ hasProfile: hasBeenEnriched,
211
+ needsProfileGeneration,
212
+ needsUserInfo,
213
+ missingUserInfo,
214
+ forceUpdate: state.forceUpdate,
215
+ hasInput: !!state.input,
216
+ hasMeaningfulInput,
207
217
  });
208
- };
209
- // ─────────────────────────────────────────────────────────
210
- // NODE: Scrape
211
- // Scrapes data from web if input is not provided
212
- // ─────────────────────────────────────────────────────────
213
- const scrapeNode = async (state) => {
214
- return timed("ProfileGraph.scrape", async () => {
215
- if (state.input && isMeaningfulProfileInput(state.input)) {
216
- logger.verbose("Meaningful input already provided - skipping scrape");
217
- return {};
218
+ return {
219
+ // Only treat the (users-sourced) profile as existing state when the user has
220
+ // actually been enriched; un-enriched users keep `undefined` so the generate
221
+ // path runs from scratch rather than merging into an empty users row.
222
+ profile: hasBeenEnriched ? (profile || undefined) : undefined,
223
+ needsProfileGeneration,
224
+ needsUserInfo,
225
+ missingUserInfo
226
+ };
227
+ }
228
+ catch (error) {
229
+ logger.error("Failed to load profile", {
230
+ error: error instanceof Error ? error.message : String(error)
231
+ });
232
+ return {
233
+ profile: undefined,
234
+ error: `Failed to load profile from database: ${error instanceof Error ? error.message : String(error)}`
235
+ };
236
+ }
237
+ });
238
+ }
239
+ // ─────────────────────────────────────────────────────────
240
+ // NODE: Scrape
241
+ // Scrapes data from web if input is not provided
242
+ // ─────────────────────────────────────────────────────────
243
+ export async function scrapeNode(state, deps) {
244
+ return timed("ProfileGraph.scrape", async () => {
245
+ if (state.input && isMeaningfulProfileInput(state.input)) {
246
+ logger.verbose("Meaningful input already provided - skipping scrape");
247
+ return {};
248
+ }
249
+ logger.verbose("Starting web scrape...", {
250
+ userId: state.userId
251
+ });
252
+ try {
253
+ // Fetch user details to construct objective for web scraping
254
+ const user = await deps.database.getUser(state.userId);
255
+ if (!user) {
256
+ logger.error("User not found", { userId: state.userId });
257
+ return {
258
+ error: `User not found: ${state.userId}`
259
+ };
260
+ }
261
+ // Build scraping objective from available user information
262
+ // Priority: social links (most reliable) > name + location > email
263
+ const socialParts = [];
264
+ const socials = await deps.database.getUserSocials(state.userId);
265
+ for (const s of socials) {
266
+ switch (s.label) {
267
+ case 'twitter':
268
+ socialParts.push(`X/Twitter: ${s.value}`);
269
+ break;
270
+ case 'linkedin':
271
+ socialParts.push(`LinkedIn: ${s.value}`);
272
+ break;
273
+ case 'github':
274
+ socialParts.push(`GitHub: ${s.value}`);
275
+ break;
276
+ case 'telegram':
277
+ socialParts.push(`Telegram: ${s.value}`);
278
+ break;
279
+ default:
280
+ socialParts.push(`Website: ${s.value}`);
281
+ break;
218
282
  }
219
- logger.verbose("Starting web scrape...", {
220
- userId: state.userId
221
- });
222
- try {
223
- // Fetch user details to construct objective for web scraping
224
- const user = await this.database.getUser(state.userId);
225
- if (!user) {
226
- logger.error("User not found", { userId: state.userId });
227
- return {
228
- error: `User not found: ${state.userId}`
229
- };
283
+ }
284
+ // Construct objective based on what we have
285
+ let objective = `Find information about ${user.name || 'this person'}`;
286
+ if (user.location) {
287
+ objective += ` located in ${user.location}`;
288
+ }
289
+ objective += '.\n\n';
290
+ if (socialParts.length > 0) {
291
+ objective += `Their social profiles:\n${socialParts.join('\n')}\n\n`;
292
+ objective += 'Use these links to find accurate information about their professional background, skills, and interests.';
293
+ }
294
+ else if (user.email) {
295
+ objective += `Their email: ${user.email}\n\n`;
296
+ objective += 'Search for professional information, skills, and background about this person.';
297
+ }
298
+ else {
299
+ objective += 'Search for professional information and background about this person.';
300
+ }
301
+ logger.verbose("Constructed scraping objective", {
302
+ hasSocials: socialParts.length > 0,
303
+ hasLocation: !!user.location,
304
+ objectivePreview: objective.substring(0, 100)
305
+ });
306
+ const scrapedData = await deps.scraper.scrape(objective);
307
+ logger.verbose("✅ Scrape complete", {
308
+ dataLength: scrapedData?.length || 0
309
+ });
310
+ return {
311
+ objective,
312
+ input: scrapedData,
313
+ activeSocialIds: socials.map(s => s.id),
314
+ operationsPerformed: { scraped: true }
315
+ };
316
+ }
317
+ catch (error) {
318
+ logger.error("Scrape failed", {
319
+ error: error instanceof Error ? error.message : String(error)
320
+ });
321
+ return {
322
+ error: `Web scrape failed: ${error instanceof Error ? error.message : String(error)}`
323
+ };
324
+ }
325
+ });
326
+ }
327
+ // ─────────────────────────────────────────────────────────
328
+ // NODE: Auto-Generate (Parallel Chat API enrichment)
329
+ // Calls enrichUserProfile to get structured data, then builds a
330
+ // text blob as input for the decompose → aggregate → generate
331
+ // pipeline. This ensures enriched users get premises with
332
+ // embeddings, making them discoverable via premise-to-premise
333
+ // matching.
334
+ // On failure, falls back to basic user info for LLM generation.
335
+ // Used in 'generate' mode only.
336
+ // ─────────────────────────────────────────────────────────
337
+ export async function autoGenerateNode(state, deps) {
338
+ return timed("ProfileGraph.autoGenerate", async () => {
339
+ logger.verbose("Starting auto-generate via Chat API enrichment", {
340
+ userId: state.userId,
341
+ });
342
+ try {
343
+ const user = await deps.database.getUser(state.userId);
344
+ if (!user) {
345
+ logger.error("User not found for auto-generate", { userId: state.userId });
346
+ return { error: `User not found: ${state.userId}` };
347
+ }
348
+ const socials = await deps.database.getUserSocials(state.userId);
349
+ const enrichmentSocials = socialsToEnrichmentRequest(socials);
350
+ const request = {
351
+ name: user.name || undefined,
352
+ email: user.email || undefined,
353
+ linkedin: enrichmentSocials.linkedin || undefined,
354
+ twitter: enrichmentSocials.twitter || undefined,
355
+ github: enrichmentSocials.github || undefined,
356
+ telegram: enrichmentSocials.telegram || undefined,
357
+ websites: enrichmentSocials.websites?.length ? enrichmentSocials.websites : undefined,
358
+ };
359
+ const buildBasicInfo = () => {
360
+ const parts = [
361
+ user.name ? `Name: ${user.name}` : '',
362
+ user.location ? `Location: ${user.location}` : '',
363
+ user.intro ? `Bio: ${user.intro}` : '',
364
+ ].filter(Boolean).join('\n');
365
+ return parts || "No information available";
366
+ };
367
+ if (!deps.enricher) {
368
+ logger.warn("No enricher configured — falling back to basic info", { userId: state.userId });
369
+ return {
370
+ input: buildBasicInfo(),
371
+ needsUserInfo: false,
372
+ needsProfileGeneration: true,
373
+ operationsPerformed: { scraped: true },
374
+ };
375
+ }
376
+ try {
377
+ const enrichment = await deps.enricher.enrichUserProfile(request);
378
+ if (enrichment && !enrichment.isHuman) {
379
+ logger.info("Enrichment detected non-human entity, soft-deleting ghost", { userId: state.userId });
380
+ await deps.database.softDeleteGhost(state.userId);
381
+ return { error: "Non-human entity detected" };
382
+ }
383
+ const hasMeaningfulEnrichment = !!enrichment &&
384
+ enrichment.confidentMatch &&
385
+ (enrichment.identity.bio.trim().length > 0 ||
386
+ enrichment.narrative.context.trim().length > 0 ||
387
+ enrichment.attributes.skills.length > 0 ||
388
+ enrichment.attributes.interests.length > 0);
389
+ if (hasMeaningfulEnrichment) {
390
+ if (user.isGhost && !isEnrichedNameMeaningful(user.email || '', enrichment.identity.name || '')) {
391
+ logger.info("Enrichment has content but no real name for ghost, soft-deleting", { userId: state.userId });
392
+ await deps.database.softDeleteGhost(state.userId);
393
+ return { error: "No real name found for ghost user" };
230
394
  }
231
- // Build scraping objective from available user information
232
- // Priority: social links (most reliable) > name + location > email
233
- const socialParts = [];
234
- const socials = await this.database.getUserSocials(state.userId);
235
- for (const s of socials) {
236
- switch (s.label) {
237
- case 'twitter':
238
- socialParts.push(`X/Twitter: ${s.value}`);
239
- break;
240
- case 'linkedin':
241
- socialParts.push(`LinkedIn: ${s.value}`);
242
- break;
243
- case 'github':
244
- socialParts.push(`GitHub: ${s.value}`);
245
- break;
246
- case 'telegram':
247
- socialParts.push(`Telegram: ${s.value}`);
248
- break;
249
- default:
250
- socialParts.push(`Website: ${s.value}`);
251
- break;
252
- }
395
+ logger.verbose("Chat API enrichment succeeded", {
396
+ userId: state.userId,
397
+ skillsCount: enrichment.attributes.skills.length,
398
+ interestsCount: enrichment.attributes.interests.length,
399
+ });
400
+ // Update user record with enriched data
401
+ const updatePayload = {};
402
+ const enrichedName = enrichment.identity.name?.trim();
403
+ if (enrichedName &&
404
+ shouldEnrichGhostDisplayNameFromParallel({ isGhost: !!user.isGhost, name: user.name ?? '', email: user.email ?? '' }, enrichedName)) {
405
+ updatePayload.name = enrichedName;
253
406
  }
254
- // Construct objective based on what we have
255
- let objective = `Find information about ${user.name || 'this person'}`;
256
- if (user.location) {
257
- objective += ` located in ${user.location}`;
407
+ if (enrichment.identity.bio?.trim())
408
+ updatePayload.intro = enrichment.identity.bio.trim();
409
+ if (enrichment.identity.location?.trim())
410
+ updatePayload.location = enrichment.identity.location.trim();
411
+ const enrichedSocials = [];
412
+ if (enrichment.socials.twitter)
413
+ enrichedSocials.push({ label: 'twitter', value: enrichment.socials.twitter });
414
+ if (enrichment.socials.linkedin)
415
+ enrichedSocials.push({ label: 'linkedin', value: enrichment.socials.linkedin });
416
+ if (enrichment.socials.github)
417
+ enrichedSocials.push({ label: 'github', value: enrichment.socials.github });
418
+ if (enrichment.socials.telegram)
419
+ enrichedSocials.push({ label: 'telegram', value: enrichment.socials.telegram });
420
+ if (enrichment.socials.websites?.length) {
421
+ for (const w of enrichment.socials.websites)
422
+ enrichedSocials.push({ label: 'custom', value: w });
258
423
  }
259
- objective += '.\n\n';
260
- if (socialParts.length > 0) {
261
- objective += `Their social profiles:\n${socialParts.join('\n')}\n\n`;
262
- objective += 'Use these links to find accurate information about their professional background, skills, and interests.';
424
+ if (enrichedSocials.length > 0) {
425
+ const existingSocials = await deps.database.getUserSocials(state.userId);
426
+ const enrichedLabels = new Set(enrichedSocials.map(s => s.label));
427
+ const kept = existingSocials
428
+ .filter(s => !enrichedLabels.has(s.label) || s.label === 'custom')
429
+ .map(s => ({ label: s.label, value: s.value }));
430
+ const merged = enrichedLabels.has('custom')
431
+ ? [...kept.filter(s => s.label !== 'custom'), ...enrichedSocials]
432
+ : [...kept, ...enrichedSocials];
433
+ await deps.database.setUserSocials(state.userId, merged);
263
434
  }
264
- else if (user.email) {
265
- objective += `Their email: ${user.email}\n\n`;
266
- objective += 'Search for professional information, skills, and background about this person.';
435
+ if (Object.keys(updatePayload).length > 0) {
436
+ await deps.database.updateUser(state.userId, updatePayload);
267
437
  }
268
- else {
269
- objective += 'Search for professional information and background about this person.';
438
+ // Post-enrichment dedup: check if this ghost matches an existing user
439
+ if (user.isGhost) {
440
+ const currentSocials = await deps.database.getUserSocials(state.userId);
441
+ const duplicate = await deps.database.findDuplicateUser(state.userId, currentSocials);
442
+ if (duplicate) {
443
+ logger.info("Post-enrichment dedup: merging ghost into existing user", {
444
+ ghostId: state.userId,
445
+ targetId: duplicate.id,
446
+ });
447
+ await deps.database.mergeGhostUser(state.userId, duplicate.id);
448
+ return { error: `Merged as duplicate of user ${duplicate.id}` };
449
+ }
270
450
  }
271
- logger.verbose("Constructed scraping objective", {
272
- hasSocials: socialParts.length > 0,
273
- hasLocation: !!user.location,
274
- objectivePreview: objective.substring(0, 100)
275
- });
276
- const scrapedData = await this.scraper.scrape(objective);
277
- logger.verbose("✅ Scrape complete", {
278
- dataLength: scrapedData?.length || 0
279
- });
451
+ // Build a text blob from the enrichment result so it flows
452
+ // through premise decomposition (when available) rather than
453
+ // bypassing premises via prePopulatedProfile.
454
+ const enrichmentParts = [
455
+ enrichment.identity.name ? `My name is ${enrichment.identity.name}.` : '',
456
+ enrichment.identity.location ? `I am based in ${enrichment.identity.location}.` : '',
457
+ enrichment.identity.bio || '',
458
+ enrichment.narrative.context || '',
459
+ enrichment.attributes.skills.length ? `My skills include ${enrichment.attributes.skills.join(', ')}.` : '',
460
+ enrichment.attributes.interests.length ? `My interests include ${enrichment.attributes.interests.join(', ')}.` : '',
461
+ ].filter(Boolean).join('\n');
280
462
  return {
281
- objective,
282
- input: scrapedData,
463
+ input: enrichmentParts,
464
+ needsUserInfo: false,
465
+ needsProfileGeneration: true,
466
+ forceUpdate: true,
283
467
  activeSocialIds: socials.map(s => s.id),
284
- operationsPerformed: { scraped: true }
468
+ operationsPerformed: { scraped: true },
285
469
  };
286
470
  }
287
- catch (error) {
288
- logger.error("Scrape failed", {
289
- error: error instanceof Error ? error.message : String(error)
290
- });
291
- return {
292
- error: `Web scrape failed: ${error instanceof Error ? error.message : String(error)}`
293
- };
471
+ if (user.isGhost) {
472
+ logger.info("Low-confidence enrichment for ghost, soft-deleting", { userId: state.userId });
473
+ await deps.database.softDeleteGhost(state.userId);
474
+ return { error: "Enrichment not confident for ghost user" };
294
475
  }
295
- });
296
- };
297
- // ─────────────────────────────────────────────────────────
298
- // NODE: Auto-Generate (Parallel Chat API enrichment)
299
- // Calls enrichUserProfile to get structured data, then builds a
300
- // text blob as input for the decompose → aggregate → generate
301
- // pipeline. This ensures enriched users get premises with
302
- // embeddings, making them discoverable via premise-to-premise
303
- // matching.
304
- // On failure, falls back to basic user info for LLM generation.
305
- // Used in 'generate' mode only.
306
- // ─────────────────────────────────────────────────────────
307
- const autoGenerateNode = async (state) => {
308
- return timed("ProfileGraph.autoGenerate", async () => {
309
- logger.verbose("Starting auto-generate via Chat API enrichment", {
476
+ logger.warn("Chat API returned low-signal enrichment, falling back to basic info", { userId: state.userId });
477
+ }
478
+ catch (enrichErr) {
479
+ if (user.isGhost) {
480
+ logger.info("Enrichment failed for ghost, soft-deleting", { userId: state.userId });
481
+ await deps.database.softDeleteGhost(state.userId);
482
+ return { error: "Enrichment failed for ghost user" };
483
+ }
484
+ logger.warn("Chat API enrichment failed, falling back to basic info", {
310
485
  userId: state.userId,
486
+ error: enrichErr instanceof Error ? enrichErr.message : String(enrichErr),
311
487
  });
488
+ }
489
+ return {
490
+ input: buildBasicInfo(),
491
+ needsUserInfo: false,
492
+ needsProfileGeneration: true,
493
+ operationsPerformed: { scraped: true },
494
+ };
495
+ }
496
+ catch (err) {
497
+ logger.error("Auto-generate failed", {
498
+ error: err instanceof Error ? err.message : String(err),
499
+ });
500
+ return { error: `Auto-generate failed: ${err instanceof Error ? err.message : String(err)}` };
501
+ }
502
+ });
503
+ }
504
+ // ─────────────────────────────────────────────────────────
505
+ // NODE: Decompose Premises
506
+ // Decomposes free-text input (chat or scraped) into individual premises
507
+ // and creates each via the premise graph. Premise creation is the terminal
508
+ // effect: premise lifecycle events drive user_context regeneration downstream
509
+ // (the legacy aggregate→generate→save profile tail was removed in WS8/IND-365,
510
+ // along with the user_profiles table it wrote to).
511
+ // ─────────────────────────────────────────────────────────
512
+ export async function decomposePremisesNode(state, deps) {
513
+ return timed("ProfileGraph.decomposePremises", async () => {
514
+ if (!state.input) {
515
+ logger.error("No input for premise decomposition");
516
+ return { error: "Input required for premise decomposition" };
517
+ }
518
+ if (!deps.premiseGraph) {
519
+ // No premise graph injected — nothing to decompose into. End the run.
520
+ logger.warn("No premise graph injected — skipping premise decomposition");
521
+ return {};
522
+ }
523
+ logger.verbose("Decomposing input into premises...", {
524
+ userId: state.userId,
525
+ inputLength: state.input.length,
526
+ });
527
+ const agentTimingsAccum = [];
528
+ try {
529
+ const _traceEmitter = requestContext.getStore()?.traceEmitter;
530
+ // Offer the user's ACTIVE premises to the decomposer so removal/denial
531
+ // instructions ("remove all mentions of X", "I have nothing to do with Y")
532
+ // can be resolved to concrete retractions instead of being silently dropped.
533
+ // Only when the adapter supports retraction — otherwise skip the lookup.
534
+ let activePremises = [];
535
+ if (typeof deps.database.updatePremise === 'function') {
312
536
  try {
313
- const user = await this.database.getUser(state.userId);
314
- if (!user) {
315
- logger.error("User not found for auto-generate", { userId: state.userId });
316
- return { error: `User not found: ${state.userId}` };
317
- }
318
- const socials = await this.database.getUserSocials(state.userId);
319
- const enrichmentSocials = socialsToEnrichmentRequest(socials);
320
- const request = {
321
- name: user.name || undefined,
322
- email: user.email || undefined,
323
- linkedin: enrichmentSocials.linkedin || undefined,
324
- twitter: enrichmentSocials.twitter || undefined,
325
- github: enrichmentSocials.github || undefined,
326
- telegram: enrichmentSocials.telegram || undefined,
327
- websites: enrichmentSocials.websites?.length ? enrichmentSocials.websites : undefined,
328
- };
329
- const buildBasicInfo = () => {
330
- const parts = [
331
- user.name ? `Name: ${user.name}` : '',
332
- user.location ? `Location: ${user.location}` : '',
333
- user.intro ? `Bio: ${user.intro}` : '',
334
- ].filter(Boolean).join('\n');
335
- return parts || "No information available";
336
- };
337
- if (!this.enricher) {
338
- logger.warn("No enricher configured — falling back to basic info", { userId: state.userId });
339
- return {
340
- input: buildBasicInfo(),
341
- needsUserInfo: false,
342
- needsProfileGeneration: true,
343
- operationsPerformed: { scraped: true },
344
- };
345
- }
346
- try {
347
- const enrichment = await this.enricher.enrichUserProfile(request);
348
- if (enrichment && !enrichment.isHuman) {
349
- logger.info("Enrichment detected non-human entity, soft-deleting ghost", { userId: state.userId });
350
- await this.database.softDeleteGhost(state.userId);
351
- return { error: "Non-human entity detected" };
352
- }
353
- const hasMeaningfulEnrichment = !!enrichment &&
354
- enrichment.confidentMatch &&
355
- (enrichment.identity.bio.trim().length > 0 ||
356
- enrichment.narrative.context.trim().length > 0 ||
357
- enrichment.attributes.skills.length > 0 ||
358
- enrichment.attributes.interests.length > 0);
359
- if (hasMeaningfulEnrichment) {
360
- if (user.isGhost && !isEnrichedNameMeaningful(user.email || '', enrichment.identity.name || '')) {
361
- logger.info("Enrichment has content but no real name for ghost, soft-deleting", { userId: state.userId });
362
- await this.database.softDeleteGhost(state.userId);
363
- return { error: "No real name found for ghost user" };
364
- }
365
- logger.verbose("Chat API enrichment succeeded", {
366
- userId: state.userId,
367
- skillsCount: enrichment.attributes.skills.length,
368
- interestsCount: enrichment.attributes.interests.length,
369
- });
370
- // Update user record with enriched data
371
- const updatePayload = {};
372
- const enrichedName = enrichment.identity.name?.trim();
373
- if (enrichedName &&
374
- shouldEnrichGhostDisplayNameFromParallel({ isGhost: !!user.isGhost, name: user.name ?? '', email: user.email ?? '' }, enrichedName)) {
375
- updatePayload.name = enrichedName;
376
- }
377
- if (enrichment.identity.bio?.trim())
378
- updatePayload.intro = enrichment.identity.bio.trim();
379
- if (enrichment.identity.location?.trim())
380
- updatePayload.location = enrichment.identity.location.trim();
381
- const enrichedSocials = [];
382
- if (enrichment.socials.twitter)
383
- enrichedSocials.push({ label: 'twitter', value: enrichment.socials.twitter });
384
- if (enrichment.socials.linkedin)
385
- enrichedSocials.push({ label: 'linkedin', value: enrichment.socials.linkedin });
386
- if (enrichment.socials.github)
387
- enrichedSocials.push({ label: 'github', value: enrichment.socials.github });
388
- if (enrichment.socials.telegram)
389
- enrichedSocials.push({ label: 'telegram', value: enrichment.socials.telegram });
390
- if (enrichment.socials.websites?.length) {
391
- for (const w of enrichment.socials.websites)
392
- enrichedSocials.push({ label: 'custom', value: w });
393
- }
394
- if (enrichedSocials.length > 0) {
395
- const existingSocials = await this.database.getUserSocials(state.userId);
396
- const enrichedLabels = new Set(enrichedSocials.map(s => s.label));
397
- const kept = existingSocials
398
- .filter(s => !enrichedLabels.has(s.label) || s.label === 'custom')
399
- .map(s => ({ label: s.label, value: s.value }));
400
- const merged = enrichedLabels.has('custom')
401
- ? [...kept.filter(s => s.label !== 'custom'), ...enrichedSocials]
402
- : [...kept, ...enrichedSocials];
403
- await this.database.setUserSocials(state.userId, merged);
404
- }
405
- if (Object.keys(updatePayload).length > 0) {
406
- await this.database.updateUser(state.userId, updatePayload);
407
- }
408
- // Post-enrichment dedup: check if this ghost matches an existing user
409
- if (user.isGhost) {
410
- const currentSocials = await this.database.getUserSocials(state.userId);
411
- const duplicate = await this.database.findDuplicateUser(state.userId, currentSocials);
412
- if (duplicate) {
413
- logger.info("Post-enrichment dedup: merging ghost into existing user", {
414
- ghostId: state.userId,
415
- targetId: duplicate.id,
416
- });
417
- await this.database.mergeGhostUser(state.userId, duplicate.id);
418
- return { error: `Merged as duplicate of user ${duplicate.id}` };
419
- }
420
- }
421
- // Build a text blob from the enrichment result so it flows
422
- // through premise decomposition (when available) rather than
423
- // bypassing premises via prePopulatedProfile.
424
- const enrichmentParts = [
425
- enrichment.identity.name ? `My name is ${enrichment.identity.name}.` : '',
426
- enrichment.identity.location ? `I am based in ${enrichment.identity.location}.` : '',
427
- enrichment.identity.bio || '',
428
- enrichment.narrative.context || '',
429
- enrichment.attributes.skills.length ? `My skills include ${enrichment.attributes.skills.join(', ')}.` : '',
430
- enrichment.attributes.interests.length ? `My interests include ${enrichment.attributes.interests.join(', ')}.` : '',
431
- ].filter(Boolean).join('\n');
432
- return {
433
- input: enrichmentParts,
434
- needsUserInfo: false,
435
- needsProfileGeneration: true,
436
- forceUpdate: true,
437
- activeSocialIds: socials.map(s => s.id),
438
- operationsPerformed: { scraped: true },
439
- };
440
- }
441
- if (user.isGhost) {
442
- logger.info("Low-confidence enrichment for ghost, soft-deleting", { userId: state.userId });
443
- await this.database.softDeleteGhost(state.userId);
444
- return { error: "Enrichment not confident for ghost user" };
445
- }
446
- logger.warn("Chat API returned low-signal enrichment, falling back to basic info", { userId: state.userId });
447
- }
448
- catch (enrichErr) {
449
- if (user.isGhost) {
450
- logger.info("Enrichment failed for ghost, soft-deleting", { userId: state.userId });
451
- await this.database.softDeleteGhost(state.userId);
452
- return { error: "Enrichment failed for ghost user" };
453
- }
454
- logger.warn("Chat API enrichment failed, falling back to basic info", {
455
- userId: state.userId,
456
- error: enrichErr instanceof Error ? enrichErr.message : String(enrichErr),
457
- });
458
- }
459
- return {
460
- input: buildBasicInfo(),
461
- needsUserInfo: false,
462
- needsProfileGeneration: true,
463
- operationsPerformed: { scraped: true },
464
- };
537
+ activePremises = await deps.database.getPremisesForUser(state.userId, 'ACTIVE');
465
538
  }
466
539
  catch (err) {
467
- logger.error("Auto-generate failed", {
540
+ logger.warn("Failed to load active premises for retraction matching — proceeding without", {
468
541
  error: err instanceof Error ? err.message : String(err),
469
542
  });
470
- return { error: `Auto-generate failed: ${err instanceof Error ? err.message : String(err)}` };
471
543
  }
544
+ }
545
+ const decomposeStart = Date.now();
546
+ _traceEmitter?.({ type: "agent_start", name: "premise-decomposer" });
547
+ // Offer the stored bio (users.intro) as well: it is a separate identity
548
+ // field that premises never touch, so removal instructions must also be
549
+ // able to rewrite it — otherwise disavowed facts survive in the bio and
550
+ // resurface in every prompt that includes the profile identity.
551
+ const currentBio = state.profile?.identity?.bio ?? '';
552
+ const result = await deps.premiseDecomposer.invoke(state.input, activePremises.map((p) => ({ id: p.id, text: p.assertion.text })), currentBio);
553
+ const decomposeMs = Date.now() - decomposeStart;
554
+ const retractionIds = result.retractedPremiseIds ?? [];
555
+ agentTimingsAccum.push({ name: "premise.decomposer", durationMs: decomposeMs });
556
+ _traceEmitter?.({
557
+ type: "agent_end",
558
+ name: "premise-decomposer",
559
+ durationMs: decomposeMs,
560
+ summary: `Decomposed into ${result.premises.length} premise(s), ${retractionIds.length} retraction(s)`,
472
561
  });
473
- };
474
- // ─────────────────────────────────────────────────────────
475
- // NODE: Decompose Premises
476
- // Decomposes free-text input (chat or scraped) into individual premises
477
- // and creates each via the premise graph. Premise creation is the terminal
478
- // effect: premise lifecycle events drive user_context regeneration downstream
479
- // (the legacy aggregate→generate→save profile tail was removed in WS8/IND-365,
480
- // along with the user_profiles table it wrote to).
481
- // ─────────────────────────────────────────────────────────
482
- const decomposePremisesNode = async (state) => {
483
- return timed("ProfileGraph.decomposePremises", async () => {
484
- if (!state.input) {
485
- logger.error("No input for premise decomposition");
486
- return { error: "Input required for premise decomposition" };
562
+ // Apply retractions FIRST so a premise that is simultaneously disavowed and
563
+ // re-asserted in corrected form does not dedupe the new create against the
564
+ // stale active row.
565
+ let retracted = 0;
566
+ for (const premiseId of retractionIds) {
567
+ if (!deps.database.updatePremise)
568
+ break;
569
+ try {
570
+ await deps.database.updatePremise(premiseId, {
571
+ status: 'RETRACTED',
572
+ retractedAt: new Date(),
573
+ });
574
+ retracted++;
487
575
  }
488
- if (!this.premiseGraph) {
489
- // No premise graph injected — nothing to decompose into. End the run.
490
- logger.warn("No premise graph injected — skipping premise decomposition");
491
- return {};
576
+ catch (err) {
577
+ logger.warn("Premise retraction failed", {
578
+ premiseId,
579
+ error: err instanceof Error ? err.message : String(err),
580
+ });
492
581
  }
493
- logger.verbose("Decomposing input into premises...", {
582
+ }
583
+ if (retracted > 0) {
584
+ logger.verbose('Retracted premises disavowed by input', {
585
+ retracted,
586
+ requested: retractionIds.length,
494
587
  userId: state.userId,
495
- inputLength: state.input.length,
496
588
  });
497
- const agentTimingsAccum = [];
589
+ }
590
+ // Apply the bio revision (before the no-new-premises early return: a pure
591
+ // removal instruction extracts zero premises but still rewrites the bio).
592
+ let bioRevised = false;
593
+ const revisedBio = result.revisedBio?.trim();
594
+ if (revisedBio && revisedBio !== currentBio.trim()) {
498
595
  try {
499
- const _traceEmitter = requestContext.getStore()?.traceEmitter;
500
- // Offer the user's ACTIVE premises to the decomposer so removal/denial
501
- // instructions ("remove all mentions of X", "I have nothing to do with Y")
502
- // can be resolved to concrete retractions instead of being silently dropped.
503
- // Only when the adapter supports retraction — otherwise skip the lookup.
504
- let activePremises = [];
505
- if (typeof this.database.updatePremise === 'function') {
506
- try {
507
- activePremises = await this.database.getPremisesForUser(state.userId, 'ACTIVE');
508
- }
509
- catch (err) {
510
- logger.warn("Failed to load active premises for retraction matching — proceeding without", {
511
- error: err instanceof Error ? err.message : String(err),
512
- });
513
- }
514
- }
515
- const decomposeStart = Date.now();
516
- _traceEmitter?.({ type: "agent_start", name: "premise-decomposer" });
517
- // Offer the stored bio (users.intro) as well: it is a separate identity
518
- // field that premises never touch, so removal instructions must also be
519
- // able to rewrite it — otherwise disavowed facts survive in the bio and
520
- // resurface in every prompt that includes the profile identity.
521
- const currentBio = state.profile?.identity?.bio ?? '';
522
- const result = await premiseDecomposer.invoke(state.input, activePremises.map((p) => ({ id: p.id, text: p.assertion.text })), currentBio);
523
- const decomposeMs = Date.now() - decomposeStart;
524
- const retractionIds = result.retractedPremiseIds ?? [];
525
- agentTimingsAccum.push({ name: "premise.decomposer", durationMs: decomposeMs });
526
- _traceEmitter?.({
527
- type: "agent_end",
528
- name: "premise-decomposer",
529
- durationMs: decomposeMs,
530
- summary: `Decomposed into ${result.premises.length} premise(s), ${retractionIds.length} retraction(s)`,
531
- });
532
- // Apply retractions FIRST so a premise that is simultaneously disavowed and
533
- // re-asserted in corrected form does not dedupe the new create against the
534
- // stale active row.
535
- let retracted = 0;
536
- for (const premiseId of retractionIds) {
537
- if (!this.database.updatePremise)
538
- break;
539
- try {
540
- await this.database.updatePremise(premiseId, {
541
- status: 'RETRACTED',
542
- retractedAt: new Date(),
543
- });
544
- retracted++;
545
- }
546
- catch (err) {
547
- logger.warn("Premise retraction failed", {
548
- premiseId,
549
- error: err instanceof Error ? err.message : String(err),
550
- });
551
- }
552
- }
553
- if (retracted > 0) {
554
- logger.verbose('Retracted premises disavowed by input', {
555
- retracted,
556
- requested: retractionIds.length,
557
- userId: state.userId,
558
- });
559
- }
560
- // Apply the bio revision (before the no-new-premises early return: a pure
561
- // removal instruction extracts zero premises but still rewrites the bio).
562
- let bioRevised = false;
563
- const revisedBio = result.revisedBio?.trim();
564
- if (revisedBio && revisedBio !== currentBio.trim()) {
565
- try {
566
- await this.database.saveProfile(state.userId, {
567
- userId: state.userId,
568
- // Empty name/location are skipped by the identity persister — only
569
- // the bio is written.
570
- identity: { name: '', bio: revisedBio, location: '' },
571
- context: '',
572
- });
573
- bioRevised = true;
574
- logger.verbose("Revised stored bio per removal/correction instruction", { userId: state.userId });
575
- }
576
- catch (err) {
577
- logger.warn("Bio revision failed", {
578
- error: err instanceof Error ? err.message : String(err),
579
- });
580
- }
581
- }
582
- if (result.premises.length === 0) {
583
- logger.verbose("No premises extracted — nothing to create");
584
- return {
585
- agentTimings: agentTimingsAccum,
586
- ...(retracted > 0 || bioRevised ? { operationsPerformed: { decomposedPremises: true } } : {}),
587
- };
588
- }
589
- logger.verbose('Creating premises via premise graph', {
590
- count: result.premises.length,
591
- userId: state.userId,
592
- });
593
- let created = 0;
594
- let skippedDuplicates = 0;
595
- for (const p of result.premises) {
596
- try {
597
- // Contextual premises carry an LLM-inferred validity window and are
598
- // volatile (auto-retract on expiry); assertive premises do not expire.
599
- const isContextual = p.tier === 'contextual';
600
- const validUntil = isContextual && p.validityDays
601
- ? new Date(Date.now() + p.validityDays * 24 * 60 * 60 * 1000).toISOString()
602
- : undefined;
603
- const premiseResult = await invokeWithAbortSignal(this.premiseGraph, {
604
- userId: state.userId,
605
- assertionText: p.text,
606
- tier: p.tier,
607
- operationMode: 'create',
608
- volatile: isContextual,
609
- ...(validUntil ? { validUntil } : {}),
610
- ...(state.activeSocialIds?.length
611
- ? { provenanceSource: 'integration', provenanceSourceId: state.activeSocialIds[0] }
612
- : {}),
613
- });
614
- if (premiseResult.premise) {
615
- created++;
616
- }
617
- else if (premiseResult.duplicateOf) {
618
- skippedDuplicates++;
619
- }
620
- else if (premiseResult.error) {
621
- logger.warn("Premise creation failed", {
622
- text: p.text.substring(0, 60),
623
- error: premiseResult.error,
624
- });
625
- }
626
- }
627
- catch (err) {
628
- logger.warn("Premise creation threw", {
629
- text: p.text.substring(0, 60),
630
- error: err instanceof Error ? err.message : String(err),
631
- });
632
- }
633
- }
634
- logger.verbose('Created premises', {
635
- created,
636
- requested: result.premises.length,
637
- skippedDuplicates,
596
+ await deps.database.saveProfile(state.userId, {
638
597
  userId: state.userId,
598
+ // Empty name/location are skipped by the identity persister — only
599
+ // the bio is written.
600
+ identity: { name: '', bio: revisedBio, location: '' },
601
+ context: '',
639
602
  });
640
- return {
641
- agentTimings: agentTimingsAccum,
642
- operationsPerformed: { decomposedPremises: true },
643
- };
603
+ bioRevised = true;
604
+ logger.verbose("Revised stored bio per removal/correction instruction", { userId: state.userId });
644
605
  }
645
606
  catch (err) {
646
- logger.error("Premise decomposition failed", {
607
+ logger.warn("Bio revision failed", {
647
608
  error: err instanceof Error ? err.message : String(err),
648
609
  });
649
- return {
650
- agentTimings: agentTimingsAccum,
651
- };
652
610
  }
653
- });
654
- };
655
- // ─────────────────────────────────────────────────────────
656
- // ROUTING CONDITIONS
657
- // Smart conditional routing based on operation mode and missing components
658
- // ─────────────────────────────────────────────────────────
659
- /**
660
- * Route from check_state to next step based on operation mode and detected needs.
661
- */
662
- const checkStateCondition = (state) => {
663
- // Query mode: Return immediately (fast path)
664
- if (state.operationMode === 'query') {
665
- logger.verbose("Query mode - ending (fast path)");
666
- return END;
667
611
  }
668
- // Generate mode: use enrichUserProfile Chat API to auto-generate
669
- if (state.operationMode === 'generate') {
670
- logger.verbose("Generate mode - routing to auto_generate");
671
- return "auto_generate";
612
+ if (result.premises.length === 0) {
613
+ logger.verbose("No premises extracted — nothing to create");
614
+ return {
615
+ agentTimings: agentTimingsAccum,
616
+ ...(retracted > 0 || bioRevised ? { operationsPerformed: { decomposedPremises: true } } : {}),
617
+ };
672
618
  }
673
- // Check if user information is insufficient for scraping
674
- // Return early so chat graph can request the missing information
675
- if (state.needsUserInfo) {
676
- logger.verbose("⚠️ Insufficient user info - requesting from user", {
677
- missingInfo: state.missingUserInfo
678
- });
679
- return END;
680
- }
681
- // Write mode: Check what needs generation
682
- if (state.needsProfileGeneration) {
683
- // Only use provided input if it's meaningful (not just "Yes" / confirmation)
684
- if (state.input && isMeaningfulProfileInput(state.input)) {
685
- // Route through premise decomposition when a premise graph is available.
686
- // The decompose node extracts atomic premises and creates them; premise
687
- // events drive user_context regeneration. Without a premise graph there
688
- // is nothing to do, so end the run.
689
- if (this.premiseGraph) {
690
- logger.verbose("Profile generation needed — decomposing input into premises");
691
- return "decompose_premises";
619
+ logger.verbose('Creating premises via premise graph', {
620
+ count: result.premises.length,
621
+ userId: state.userId,
622
+ });
623
+ let created = 0;
624
+ let skippedDuplicates = 0;
625
+ for (const p of result.premises) {
626
+ try {
627
+ // Contextual premises carry an LLM-inferred validity window and are
628
+ // volatile (auto-retract on expiry); assertive premises do not expire.
629
+ const isContextual = p.tier === 'contextual';
630
+ const validUntil = isContextual && p.validityDays
631
+ ? new Date(Date.now() + p.validityDays * 24 * 60 * 60 * 1000).toISOString()
632
+ : undefined;
633
+ const premiseResult = await invokeWithAbortSignal(deps.premiseGraph, {
634
+ userId: state.userId,
635
+ assertionText: p.text,
636
+ tier: p.tier,
637
+ operationMode: 'create',
638
+ volatile: isContextual,
639
+ ...(validUntil ? { validUntil } : {}),
640
+ ...(state.activeSocialIds?.length
641
+ ? { provenanceSource: 'integration', provenanceSourceId: state.activeSocialIds[0] }
642
+ : {}),
643
+ });
644
+ if (premiseResult.premise) {
645
+ created++;
646
+ }
647
+ else if (premiseResult.duplicateOf) {
648
+ skippedDuplicates++;
649
+ }
650
+ else if (premiseResult.error) {
651
+ logger.warn("Premise creation failed", {
652
+ text: p.text.substring(0, 60),
653
+ error: premiseResult.error,
654
+ });
692
655
  }
693
- logger.verbose("Profile generation needed but no premise graph injected — ending");
694
- return END;
695
656
  }
696
- else {
697
- logger.verbose("Profile generation needed - scraping first (no meaningful input)");
698
- return "scrape";
657
+ catch (err) {
658
+ logger.warn("Premise creation threw", {
659
+ text: p.text.substring(0, 60),
660
+ error: err instanceof Error ? err.message : String(err),
661
+ });
699
662
  }
700
663
  }
701
- // Everything exists and is up to date
702
- logger.verbose("All components exist - ending");
703
- return END;
704
- };
705
- // ─────────────────────────────────────────────────────────
706
- // GRAPH ASSEMBLY
707
- // Conditional flow based on operation mode and detected needs
708
- // ─────────────────────────────────────────────────────────
709
- const workflow = new StateGraph(EnrichmentGraphState)
710
- // Add all nodes
711
- .addNode("check_state", checkStateNode)
712
- .addNode("scrape", scrapeNode)
713
- .addNode("decompose_premises", decomposePremisesNode)
714
- .addNode("auto_generate", autoGenerateNode)
715
- // Start with state check
716
- .addEdge(START, "check_state")
717
- // Conditional routing from check_state
718
- .addConditionalEdges("check_state", checkStateCondition, {
719
- auto_generate: "auto_generate", // Generate mode -> Chat API enrichment
720
- decompose_premises: "decompose_premises", // Write mode + input + premise graph -> decompose
721
- scrape: "scrape", // Need premises, no input -> scrape first
722
- [END]: END // Query mode, no premise graph, or everything exists
723
- })
724
- // Decompose premises creates premises as a side effect, then ends.
725
- // user_context regeneration is handled downstream by premise lifecycle events.
726
- .addEdge("decompose_premises", END)
727
- // Auto-generate routes to premise decomposition (when premise graph
728
- // available + enrichment produced input), otherwise ends.
729
- .addConditionalEdges("auto_generate", (state) => {
730
- if (state.input && this.premiseGraph) {
731
- logger.verbose("Enrichment produced input routing to premise decomposition");
664
+ logger.verbose('Created premises', {
665
+ created,
666
+ requested: result.premises.length,
667
+ skippedDuplicates,
668
+ userId: state.userId,
669
+ });
670
+ return {
671
+ agentTimings: agentTimingsAccum,
672
+ operationsPerformed: { decomposedPremises: true },
673
+ };
674
+ }
675
+ catch (err) {
676
+ logger.error("Premise decomposition failed", {
677
+ error: err instanceof Error ? err.message : String(err),
678
+ });
679
+ return {
680
+ agentTimings: agentTimingsAccum,
681
+ };
682
+ }
683
+ });
684
+ }
685
+ /**
686
+ * Route from check_state to next step based on operation mode and detected needs.
687
+ */
688
+ export function checkStateCondition(state, deps) {
689
+ // Query mode: Return immediately (fast path)
690
+ if (state.operationMode === 'query') {
691
+ logger.verbose("Query mode - ending (fast path)");
692
+ return END;
693
+ }
694
+ // Generate mode: use enrichUserProfile Chat API to auto-generate
695
+ if (state.operationMode === 'generate') {
696
+ logger.verbose("Generate mode - routing to auto_generate");
697
+ return "auto_generate";
698
+ }
699
+ // Check if user information is insufficient for scraping
700
+ // Return early so chat graph can request the missing information
701
+ if (state.needsUserInfo) {
702
+ logger.verbose("⚠️ Insufficient user info - requesting from user", {
703
+ missingInfo: state.missingUserInfo
704
+ });
705
+ return END;
706
+ }
707
+ // Write mode: Check what needs generation
708
+ if (state.needsProfileGeneration) {
709
+ // Only use provided input if it's meaningful (not just "Yes" / confirmation)
710
+ if (state.input && isMeaningfulProfileInput(state.input)) {
711
+ // Route through premise decomposition when a premise graph is available.
712
+ // The decompose node extracts atomic premises and creates them; premise
713
+ // events drive user_context regeneration. Without a premise graph there
714
+ // is nothing to do, so end the run.
715
+ if (deps.premiseGraph) {
716
+ logger.verbose("Profile generation needed — decomposing input into premises");
732
717
  return "decompose_premises";
733
718
  }
734
- logger.verbose("Enrichment ended without usable input (ghost soft-deleted, error, or no premise graph)done");
719
+ logger.verbose("Profile generation needed but no premise graph injected ending");
735
720
  return END;
736
- }, {
737
- decompose_premises: "decompose_premises",
738
- [END]: END,
739
- })
740
- // Scrape -> decompose_premises (when premise graph available), else ends.
741
- .addConditionalEdges("scrape", (_state) => {
742
- if (this.premiseGraph)
743
- return "decompose_premises";
744
- return END;
745
- }, {
746
- decompose_premises: "decompose_premises",
747
- [END]: END,
748
- });
749
- logger.verbose("Graph built successfully");
750
- return workflow.compile();
721
+ }
722
+ else {
723
+ logger.verbose("Profile generation needed - scraping first (no meaningful input)");
724
+ return "scrape";
725
+ }
751
726
  }
727
+ // Everything exists and is up to date
728
+ logger.verbose("All components exist - ending");
729
+ return END;
752
730
  }