fuli-context 0.5.0 → 0.7.3

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 (206) hide show
  1. package/README.md +370 -202
  2. package/README.zh-CN.md +448 -0
  3. package/compose.graphiti.yml +6 -6
  4. package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
  5. package/dist/web/assets/AboutPage-qeO_vm0T.js +1 -0
  6. package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
  7. package/dist/web/assets/ConnectionsPage-DO0GR9Xg.js +1 -0
  8. package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
  9. package/dist/web/assets/KnowledgeInspector-DEW5lA1m.js +1 -0
  10. package/dist/web/assets/KnowledgeOrganizerPage-BkM568Ld.js +1 -0
  11. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
  12. package/dist/web/assets/KnowledgePage-BAT4aZIm.js +1 -0
  13. package/dist/web/assets/KnowledgeWorkspace-DyGtBz--.js +4 -0
  14. package/dist/web/assets/OverviewPage-CmCcDCAs.js +1 -0
  15. package/dist/web/assets/PersonalProfilePage-C42mwKN8.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
  17. package/dist/web/assets/PersonalProjectsPage-BwaXR62K.js +1 -0
  18. package/dist/web/assets/PublicProjectsPage-egKBulOq.js +1 -0
  19. package/dist/web/assets/ReviewPage-B3vnZ6gn.js +1 -0
  20. package/dist/web/assets/SearchableSelect-ih69qbcS.js +1 -0
  21. package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
  22. package/dist/web/assets/SettingsPage-oLE8mNs5.js +1 -0
  23. package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  24. package/dist/web/assets/console-DE40hXGC.js +1 -0
  25. package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
  26. package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
  27. package/dist/web/assets/i18n-CDxBs1sz.js +5 -0
  28. package/dist/web/assets/identity-BCxb1mcy.js +1 -0
  29. package/dist/web/assets/index-BzhNfzU_.js +2 -0
  30. package/dist/web/assets/index-CCOGfZzE.css +1 -0
  31. package/dist/web/assets/model-CCMdt10Q.js +1 -0
  32. package/dist/web/assets/paths-DwHeiGMT.js +1 -0
  33. package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
  34. package/dist/web/assets/useMinimumLoadingDisplay-HdI7XAmu.js +1 -0
  35. package/dist/web/assets/vue-router-Drfl1F8i.js +1 -0
  36. package/dist/web/index.html +9 -6
  37. package/docs/external-knowledge-architecture.md +279 -0
  38. package/examples/external-knowledge/markdown-folder.mjs +235 -0
  39. package/graph-provider/fuli_graph/app.py +57 -1
  40. package/graph-provider/fuli_graph/graph_models.py +3 -0
  41. package/graph-provider/fuli_graph/graph_query.py +62 -16
  42. package/graph-provider/fuli_graph/knowledge_review.py +657 -0
  43. package/graph-provider/fuli_graph/knowledge_review_models.py +133 -0
  44. package/graph-provider/fuli_graph/knowledge_search.py +51 -4
  45. package/graph-provider/fuli_graph/models.py +19 -0
  46. package/graph-provider/fuli_graph/runtime.py +15 -0
  47. package/graph-provider/fuli_graph/store_knowledge.py +2 -1
  48. package/graph-provider/pyproject.toml +1 -1
  49. package/npm-shrinkwrap.json +5 -407
  50. package/package.json +7 -5
  51. package/skills/capturing-session-knowledge/SKILL.md +24 -4
  52. package/skills/flreview/SKILL.md +100 -0
  53. package/skills/flreview/agents/openai.yaml +6 -0
  54. package/src/agent-tools/graph-definitions.js +81 -6
  55. package/src/agent-tools/graph-handlers.js +12 -0
  56. package/src/cli/command-registry.js +3 -3
  57. package/src/cli/local-runtime-command.js +51 -28
  58. package/src/cli/local-runtime-options.js +14 -8
  59. package/src/cli/setup-agent-selection.js +12 -12
  60. package/src/cli/setup-command.js +41 -18
  61. package/src/cli/shell-argument.js +10 -0
  62. package/src/cli/uninstall-command.js +17 -15
  63. package/src/cli/update-command.js +38 -30
  64. package/src/external-knowledge/catalog.js +61 -0
  65. package/src/external-knowledge/conflict-policy.js +58 -0
  66. package/src/external-knowledge/connected-search.js +166 -0
  67. package/src/external-knowledge/connectors/custom.js +133 -0
  68. package/src/external-knowledge/connectors/feishu.js +461 -0
  69. package/src/external-knowledge/connectors/mcp.js +438 -0
  70. package/src/external-knowledge/connectors/notion.js +307 -0
  71. package/src/external-knowledge/connectors/retrieval-api.js +222 -0
  72. package/src/external-knowledge/document-mapping.js +151 -0
  73. package/src/external-knowledge/graph-projection.js +17 -0
  74. package/src/external-knowledge/index.js +8 -0
  75. package/src/external-knowledge/registry.js +91 -0
  76. package/src/external-knowledge/runtime.js +68 -0
  77. package/src/external-knowledge/safe-diagnostic.js +14 -0
  78. package/src/external-knowledge/service.js +811 -0
  79. package/src/graphiti/agent-knowledge-workflows.js +7 -2
  80. package/src/graphiti/federated-application.js +68 -42
  81. package/src/graphiti/knowledge-item-role.js +21 -0
  82. package/src/graphiti/knowledge-review.js +43 -0
  83. package/src/graphiti/project-profile-mapping.js +32 -0
  84. package/src/graphiti/provider-client.js +22 -1
  85. package/src/graphiti/source-marker.js +54 -3
  86. package/src/graphiti/task-knowledge-recall.js +250 -0
  87. package/src/http/api-router.js +23 -108
  88. package/src/http/external-knowledge-api-router.js +104 -0
  89. package/src/http/graph-api-router.js +12 -1
  90. package/src/http/request-policy.js +1 -1
  91. package/src/local-runtime/lifecycle.js +26 -15
  92. package/src/mcp/create-mcp-server.js +4 -1
  93. package/src/mcp/instructions.js +7 -7
  94. package/src/mcp/tool-annotations.js +6 -0
  95. package/src/mcp/tool-result.js +1 -0
  96. package/src/server/application-lifecycle.js +2 -11
  97. package/src/server/blocked-ports.js +11 -0
  98. package/src/server.js +36 -17
  99. package/src/setup/agent-installation-status.js +2 -1
  100. package/src/setup/agents.js +2 -1
  101. package/src/setup/claude-code-config.js +2 -1
  102. package/src/setup/codex-bootstrap.js +12 -7
  103. package/src/setup/container-runtime.js +25 -21
  104. package/src/setup/graph-runtime.js +148 -56
  105. package/src/setup/node-runtime.js +3 -3
  106. package/src/setup/options.js +2 -4
  107. package/src/setup/paths.js +18 -3
  108. package/src/setup/review-skill-adapters.js +49 -0
  109. package/src/setup/setup.js +26 -2
  110. package/src/setup/uninstall.js +4 -2
  111. package/src/system/resource-monitor.js +317 -0
  112. package/src/system/runtime-settings.js +131 -0
  113. package/src/system/system-service.js +119 -0
  114. package/README.en.md +0 -336
  115. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
  116. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
  118. package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +0 -1
  119. package/dist/web/assets/KnowledgeOrganizerPage-DlvcLwlL.js +0 -1
  120. package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
  121. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
  122. package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
  123. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
  124. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
  125. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
  126. package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
  127. package/dist/web/assets/console-B_Ena2DS.js +0 -5
  128. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  129. package/dist/web/assets/identity-CjBwYSMX.js +0 -1
  130. package/dist/web/assets/index-CpLDx3of.css +0 -1
  131. package/dist/web/assets/index-DAzeFJSc.js +0 -2
  132. package/dist/web/assets/model-C-5RVIJ2.js +0 -1
  133. package/dist/web/assets/paths-BkULqmGI.js +0 -1
  134. package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
  135. package/src/agent/candidate-read-service.js +0 -53
  136. package/src/agent/context-pack-service.js +0 -83
  137. package/src/agent/public-read-policy.js +0 -39
  138. package/src/agent/public-read-service.js +0 -69
  139. package/src/agent/read-projection.js +0 -107
  140. package/src/agent/search-service.js +0 -61
  141. package/src/agent/space-boundary.js +0 -33
  142. package/src/app/bootstrap.js +0 -8
  143. package/src/app/create-application.js +0 -143
  144. package/src/app/state-service.js +0 -38
  145. package/src/candidates.js +0 -78
  146. package/src/classifier.js +0 -63
  147. package/src/cli/command-arguments.js +0 -14
  148. package/src/context-pack.js +0 -206
  149. package/src/extractor.js +0 -77
  150. package/src/fact-history.js +0 -39
  151. package/src/facts/fact-service.js +0 -89
  152. package/src/http/bounded-json-array.js +0 -19
  153. package/src/http/lens-route.js +0 -50
  154. package/src/ingestion.js +0 -161
  155. package/src/lens/lens-correction-service.js +0 -81
  156. package/src/lens/lens-fact-writer.js +0 -85
  157. package/src/lens/lens-input.js +0 -62
  158. package/src/lens/lens-query-projection.js +0 -87
  159. package/src/lens/lens-query-ranking.js +0 -62
  160. package/src/lens/lens-query.js +0 -143
  161. package/src/lens/lens-resource-projection.js +0 -93
  162. package/src/lens/lens-resource-selection.js +0 -45
  163. package/src/lens/lens-resource-service.js +0 -103
  164. package/src/lens/lens-search-query.js +0 -122
  165. package/src/lens/lens-service.js +0 -183
  166. package/src/models.js +0 -44
  167. package/src/observer.js +0 -83
  168. package/src/project-rules.js +0 -33
  169. package/src/publication/outbox-service.js +0 -46
  170. package/src/publication/publication-envelope-schema.js +0 -75
  171. package/src/publication/publication-envelope.js +0 -37
  172. package/src/publication/publication-policy.js +0 -82
  173. package/src/publication/publication-service.js +0 -48
  174. package/src/router.js +0 -58
  175. package/src/runtime-options.js +0 -138
  176. package/src/security/episode-visibility.js +0 -14
  177. package/src/security/fact-visibility.js +0 -15
  178. package/src/security/source-metadata-policy.js +0 -8
  179. package/src/storage/episode-evidence-preview.js +0 -100
  180. package/src/storage/fact-search-order.js +0 -18
  181. package/src/storage/file/episode-evidence.js +0 -37
  182. package/src/storage/file/episode-index.js +0 -59
  183. package/src/storage/file/fact-search.js +0 -60
  184. package/src/storage/file-store.js +0 -437
  185. package/src/storage/migrate.js +0 -128
  186. package/src/storage/migrations/001-initial.sql +0 -83
  187. package/src/storage/migrations/002-storage-integrity.sql +0 -113
  188. package/src/storage/record-validation.js +0 -46
  189. package/src/storage/snapshot-defaults.js +0 -54
  190. package/src/storage/sqlite/candidates-repository.js +0 -84
  191. package/src/storage/sqlite/episode-evidence-repository.js +0 -154
  192. package/src/storage/sqlite/episode-privacy-sql.js +0 -33
  193. package/src/storage/sqlite/episodes-repository.js +0 -52
  194. package/src/storage/sqlite/fact-search-repository.js +0 -162
  195. package/src/storage/sqlite/facts-repository.js +0 -146
  196. package/src/storage/sqlite/imports-repository.js +0 -39
  197. package/src/storage/sqlite/mapper.js +0 -84
  198. package/src/storage/sqlite/open-database.js +0 -15
  199. package/src/storage/sqlite/outbox-repository.js +0 -103
  200. package/src/storage/sqlite/snapshot-repository.js +0 -69
  201. package/src/storage/sqlite/spaces-repository.js +0 -52
  202. package/src/storage/sqlite/subscriptions-repository.js +0 -54
  203. package/src/storage/sqlite-store.js +0 -159
  204. package/src/storage/stable-json.js +0 -24
  205. package/src/storage/store-port.js +0 -20
  206. package/src/store.js +0 -2
@@ -0,0 +1,811 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ import { detectSensitiveContent } from '../security/sensitive-content.js';
4
+
5
+ import {
6
+ externalDocumentEpisode,
7
+ externalDocumentHash,
8
+ externalDocumentStateKey,
9
+ normalizeExternalDocument
10
+ } from './document-mapping.js';
11
+ import { safeExternalSourceDiagnostic } from './safe-diagnostic.js';
12
+
13
+ const MODES = new Set(['hybrid', 'live', 'mirror']);
14
+ const MAX_TARGETS = 32;
15
+ const CREDENTIAL_FIELDS = new Set([
16
+ 'token',
17
+ 'accesstoken',
18
+ 'refreshtoken',
19
+ 'authtoken',
20
+ 'apikey',
21
+ 'secret',
22
+ 'clientsecret',
23
+ 'appsecret',
24
+ 'password',
25
+ 'passwd',
26
+ 'passphrase',
27
+ 'credential',
28
+ 'credentials',
29
+ 'authorization',
30
+ 'cookie',
31
+ 'privatekey',
32
+ 'signingkey',
33
+ 'clientkey'
34
+ ]);
35
+
36
+ export class ExternalKnowledgeService {
37
+ constructor({
38
+ app,
39
+ registry,
40
+ connectors,
41
+ env = process.env,
42
+ now = () => new Date(),
43
+ createId = randomUUID
44
+ }) {
45
+ if (!app || !registry || !connectors) {
46
+ throw new TypeError('External knowledge service requires app, registry, and connectors');
47
+ }
48
+ this.app = app;
49
+ this.registry = registry;
50
+ this.connectors = connectors;
51
+ this.env = env;
52
+ this.now = now;
53
+ this.createId = createId;
54
+ this.syncs = new Map();
55
+ }
56
+
57
+ listConnectorTypes() {
58
+ return this.connectors.list?.() ?? [];
59
+ }
60
+
61
+ async listBindings() {
62
+ return this.registry.list().map(publicBinding);
63
+ }
64
+
65
+ async discover(input) {
66
+ const connectorType = requiredString(input?.connectorType, 'connectorType', 64);
67
+ const connector = this.#connector(connectorType);
68
+ if (typeof connector.discover !== 'function') {
69
+ throw new TypeError(`${connectorType} does not support discovery`);
70
+ }
71
+ return connector.discover({
72
+ ...this.#context({
73
+ connectorConfig: safeConfiguration(input.connectorConfig ?? {}),
74
+ source: safeConfiguration(input.source ?? {})
75
+ }),
76
+ query: typeof input.query === 'string' ? input.query : null,
77
+ cursor: typeof input.cursor === 'string' ? input.cursor : null,
78
+ limit: positiveInteger(input.limit ?? 50, 'limit', 100)
79
+ });
80
+ }
81
+
82
+ async createBinding(input) {
83
+ const connectorType = requiredString(input?.connectorType, 'connectorType', 64);
84
+ const connector = this.#connector(connectorType);
85
+ const connectorConfig = safeConfiguration(input.connectorConfig ?? {});
86
+ const source = safeConfiguration(input.source ?? {});
87
+ const id = this.createId();
88
+ const targets = bindingTargets(input, { bindingId: id });
89
+ assertConnectorMethods(connectorType, connector, targets);
90
+ await this.#assertPersonalProjects(targets);
91
+ const checked = typeof connector.check === 'function'
92
+ ? await connector.check(this.#context({
93
+ id,
94
+ connectorConfig,
95
+ source,
96
+ targets
97
+ }))
98
+ : { status: 'ready' };
99
+ if (checked?.status && checked.status !== 'ready') {
100
+ throw new Error(checked.message ?? `${connectorType} connection is not ready`);
101
+ }
102
+ assertNegotiatedCapabilities(connectorType, checked?.capabilities, targets);
103
+ const timestamp = this.now().toISOString();
104
+ const binding = {
105
+ id,
106
+ name: requiredString(input.name, 'name', 120),
107
+ connectorType,
108
+ connectorConfig,
109
+ source,
110
+ targets,
111
+ status: 'ready',
112
+ capabilities: checked?.capabilities ?? connector.capabilities ?? [],
113
+ createdAt: timestamp,
114
+ updatedAt: timestamp
115
+ };
116
+ return publicBinding(this.registry.put(binding));
117
+ }
118
+
119
+ async updateBindingTargets(id, input) {
120
+ const binding = this.#binding(id);
121
+ const connector = this.#connector(binding.connectorType);
122
+ const targets = bindingTargets(input, {
123
+ bindingId: binding.id,
124
+ existingTargets: binding.targets,
125
+ defaultMode: commonBindingMode(binding.targets)
126
+ });
127
+ assertConnectorMethods(binding.connectorType, connector, targets);
128
+ await this.#assertPersonalProjects(targets);
129
+ const checked = typeof connector.check === 'function'
130
+ ? await connector.check(this.#context({ ...binding, targets }))
131
+ : { status: binding.status, capabilities: binding.capabilities };
132
+ if (checked?.status && checked.status !== 'ready') {
133
+ throw new Error(checked.message ?? `${binding.connectorType} connection is not ready`);
134
+ }
135
+ const capabilities = checked?.capabilities ?? binding.capabilities;
136
+ assertNegotiatedCapabilities(binding.connectorType, capabilities, targets);
137
+
138
+ const nextByKey = new Map(targets.map((target) => [targetKey(target), target]));
139
+ for (const previous of binding.targets) {
140
+ const next = nextByKey.get(targetKey(previous));
141
+ if (!next || (previous.mode !== 'live' && next.mode === 'live')) {
142
+ await this.#invalidateTarget(previous);
143
+ if (next) next.sync = emptySyncState();
144
+ }
145
+ }
146
+ binding.targets = targets;
147
+ binding.status = checked?.status ?? binding.status;
148
+ binding.capabilities = capabilities ?? [];
149
+ binding.updatedAt = this.now().toISOString();
150
+ return publicBinding(this.registry.put(binding));
151
+ }
152
+
153
+ async checkBinding(id) {
154
+ const binding = this.#binding(id);
155
+ const connector = this.#connector(binding.connectorType);
156
+ const result = typeof connector.check === 'function'
157
+ ? await connector.check(this.#context(binding))
158
+ : { status: 'ready', capabilities: connector.capabilities ?? [] };
159
+ assertNegotiatedCapabilities(binding.connectorType, result.capabilities, binding.targets);
160
+ binding.status = result.status ?? 'ready';
161
+ binding.capabilities = result.capabilities ?? binding.capabilities;
162
+ binding.updatedAt = this.now().toISOString();
163
+ this.registry.put(binding);
164
+ return { bindingId: binding.id, ...result };
165
+ }
166
+
167
+ async syncBinding(id, options = {}) {
168
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
169
+ throw new TypeError('Synchronization options must be an object');
170
+ }
171
+ const maxPages = positiveInteger(options.maxPages ?? 100, 'maxPages', 100);
172
+ const pageSize = positiveInteger(options.pageSize ?? 100, 'pageSize', 100);
173
+ const binding = this.#binding(id);
174
+ const targets = selectedTargets(binding, options, { capability: 'sync' });
175
+ const results = [];
176
+ for (const target of targets) {
177
+ const syncKey = `${id}:${target.id}`;
178
+ const pending = this.syncs.get(syncKey) ?? this.#sync(binding, target, {
179
+ maxPages,
180
+ pageSize
181
+ }).finally(() => this.syncs.delete(syncKey));
182
+ this.syncs.set(syncKey, pending);
183
+ results.push(await pending);
184
+ }
185
+ return aggregateSyncResults(id, results);
186
+ }
187
+
188
+ async retrieveBinding(id, input = {}) {
189
+ const { query, limit = 12 } = input;
190
+ const binding = this.#binding(id);
191
+ const target = selectedTargets(binding, input, {
192
+ capability: 'retrieve',
193
+ requireOne: true
194
+ })[0];
195
+ if (target.mode === 'mirror') {
196
+ throw new TypeError('This binding is configured for synchronized graph search only');
197
+ }
198
+ const connector = this.#connector(binding.connectorType);
199
+ if (typeof connector.retrieve !== 'function') {
200
+ throw new TypeError(`${binding.connectorType} does not support live retrieval`);
201
+ }
202
+ const normalizedQuery = requiredString(query, 'query', 2_000);
203
+ const resolvedLimit = positiveInteger(limit, 'limit', 100);
204
+ const result = await connector.retrieve({
205
+ ...this.#context(binding, target),
206
+ query: normalizedQuery,
207
+ limit: resolvedLimit
208
+ });
209
+ const items = [];
210
+ let skippedCredentials = 0;
211
+ for (const value of (result?.items ?? result?.hits ?? []).slice(0, resolvedLimit)) {
212
+ try {
213
+ items.push(normalizeExternalDocument(value));
214
+ } catch (error) {
215
+ if (!isCredentialDocumentError(error)) throw error;
216
+ skippedCredentials += 1;
217
+ }
218
+ }
219
+ return {
220
+ bindingId: id,
221
+ targetId: target.id,
222
+ personalProjectId: target.personalProjectId,
223
+ query: normalizedQuery,
224
+ items,
225
+ skippedCredentials
226
+ };
227
+ }
228
+
229
+ async deleteBinding(id) {
230
+ const binding = this.#binding(id);
231
+ let invalidated = 0;
232
+ for (const target of binding.targets) {
233
+ invalidated += await this.#invalidateTarget(target);
234
+ }
235
+ this.registry.delete(id);
236
+ return { bindingId: id, status: 'deleted', invalidated };
237
+ }
238
+
239
+ projectGraphProjection({ personalSpaceId, personalProjectId = null, graph }) {
240
+ const projectNodes = new Map(
241
+ (graph?.nodes ?? [])
242
+ .filter((node) => node?.type === 'PersonalProject')
243
+ .map((node) => [node?.attributes?.projectId, node])
244
+ .filter(([projectId]) => typeof projectId === 'string' && projectId)
245
+ );
246
+ const nodes = [];
247
+ const edges = [];
248
+ const projectedBindings = new Set();
249
+
250
+ for (const binding of this.registry.list()) {
251
+ const targets = binding.targets.filter((target) =>
252
+ target.personalSpaceId === personalSpaceId &&
253
+ (!personalProjectId || target.personalProjectId === personalProjectId) &&
254
+ projectNodes.has(target.personalProjectId)
255
+ );
256
+ if (!targets.length) continue;
257
+
258
+ const sourceId = `external-knowledge-source:${binding.id}`;
259
+ if (!projectedBindings.has(binding.id)) {
260
+ projectedBindings.add(binding.id);
261
+ const groupId = projectNodes.get(targets[0].personalProjectId)?.group_id;
262
+ nodes.push({
263
+ id: sourceId,
264
+ name: binding.name,
265
+ type: 'ExternalKnowledgeSource',
266
+ summary: `Read-only ${binding.connectorType} knowledge source.`,
267
+ ...(groupId ? { group_id: groupId } : {}),
268
+ origin_quadrant: 'known_known',
269
+ current_quadrant: 'known_known',
270
+ epistemic_status: 'observed',
271
+ confirmation_status: 'confirmed',
272
+ confirmation_basis: {
273
+ existence_reason: 'This external knowledge connection is configured in FULI.',
274
+ quadrant_reason: 'The connection and its project assignments are explicit configuration.',
275
+ proposed_by: { kind: 'import', label: binding.connectorType },
276
+ confirmed_by: { kind: 'user', label: 'connection configuration' },
277
+ confirmed_at: binding.updatedAt ?? binding.createdAt ?? null
278
+ },
279
+ attributes: {
280
+ externalBindingId: binding.id,
281
+ externalConnectorType: binding.connectorType,
282
+ externalBindingStatus: binding.status,
283
+ externalCapabilities: binding.capabilities ?? [],
284
+ externalTargetCount: binding.targets.length,
285
+ externalModes: [...new Set(binding.targets.map(({ mode }) => mode))]
286
+ }
287
+ });
288
+ }
289
+
290
+ for (const target of targets) {
291
+ const projectNode = projectNodes.get(target.personalProjectId);
292
+ edges.push({
293
+ id: `external-knowledge-binding:${binding.id}:${target.id}`,
294
+ source: projectNode.id,
295
+ target: sourceId,
296
+ source_name: projectNode.name,
297
+ target_name: binding.name,
298
+ type: 'USES_EXTERNAL_KNOWLEDGE',
299
+ fact: `${projectNode.name} uses the read-only external knowledge source ${binding.name}.`,
300
+ origin_quadrant: 'known_known',
301
+ current_quadrant: 'known_known',
302
+ epistemic_status: 'observed',
303
+ confirmation_status: 'confirmed',
304
+ attributes: {
305
+ externalBindingId: binding.id,
306
+ externalBindingTargetId: target.id,
307
+ mode: target.mode,
308
+ status: target.status,
309
+ lastSyncedAt: target.sync?.lastSyncedAt ?? null
310
+ }
311
+ });
312
+ }
313
+ }
314
+ return { nodes, edges };
315
+ }
316
+
317
+ async #sync(binding, target, { maxPages, pageSize }) {
318
+ if (target.mode === 'live') {
319
+ throw new TypeError('This binding is configured for live retrieval only');
320
+ }
321
+ const connector = this.#connector(binding.connectorType);
322
+ let cursor = target.sync.cursor;
323
+ let imported = 0;
324
+ let unchanged = 0;
325
+ let invalidated = 0;
326
+ let skippedCredentials = 0;
327
+ let hasMore = false;
328
+ try {
329
+ for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
330
+ const page = connectorSyncPage(await connector.sync({
331
+ ...this.#context(binding, target),
332
+ cursor,
333
+ limit: pageSize
334
+ }), pageSize);
335
+ const { items, deleted } = page;
336
+ for (const value of items) {
337
+ let document;
338
+ try {
339
+ document = normalizeExternalDocument(value);
340
+ } catch (error) {
341
+ if (!isCredentialDocumentError(error)) throw error;
342
+ skippedCredentials += 1;
343
+ const rejectedId = connectorItemId(value);
344
+ if (rejectedId) {
345
+ const rejectedKey = externalDocumentStateKey(rejectedId);
346
+ const previous = target.sync.items[rejectedKey];
347
+ if (previous) {
348
+ invalidated += await this.#invalidateEntities(
349
+ previous.entityIds ?? [],
350
+ target
351
+ );
352
+ delete target.sync.items[rejectedKey];
353
+ }
354
+ }
355
+ continue;
356
+ }
357
+ const hash = externalDocumentHash(document);
358
+ const itemKey = externalDocumentStateKey(document.id);
359
+ const previous = target.sync.items[itemKey];
360
+ if (previous?.hash === hash) {
361
+ unchanged += 1;
362
+ continue;
363
+ }
364
+ const capture = await this.app.captureSessionKnowledge(externalDocumentEpisode({
365
+ binding: bindingForTarget(binding, target),
366
+ document,
367
+ now: this.now()
368
+ }));
369
+ if (capture?.status === 'capture_disabled') {
370
+ throw new Error('FULI knowledge capture is disabled');
371
+ }
372
+ invalidated += await this.#invalidateEntities(
373
+ previous?.entityIds ?? [],
374
+ target
375
+ );
376
+ target.sync.items[itemKey] = {
377
+ externalId: document.id,
378
+ hash,
379
+ entityIds: capture?.entity_ids ?? capture?.entityIds ?? [],
380
+ updatedAt: document.updatedAt,
381
+ url: document.url
382
+ };
383
+ imported += 1;
384
+ }
385
+ for (const value of deleted) {
386
+ const deletedId = connectorItemId(value);
387
+ if (!deletedId) continue;
388
+ const itemKey = externalDocumentStateKey(deletedId);
389
+ if (!target.sync.items[itemKey]) continue;
390
+ invalidated += await this.#invalidateEntities(
391
+ target.sync.items[itemKey].entityIds ?? [],
392
+ target
393
+ );
394
+ delete target.sync.items[itemKey];
395
+ }
396
+ hasMore = page?.hasMore === true;
397
+ const nextCursor = page?.nextCursor ?? null;
398
+ if (hasMore && (!nextCursor || nextCursor === cursor)) {
399
+ throw new TypeError('Connector returned an invalid pagination cursor');
400
+ }
401
+ cursor = nextCursor;
402
+ if (!hasMore) break;
403
+ if (pageNumber === maxPages - 1) {
404
+ throw new Error(`Synchronization exceeded ${maxPages} pages`);
405
+ }
406
+ }
407
+ target.status = 'ready';
408
+ binding.updatedAt = this.now().toISOString();
409
+ target.sync.cursor = cursor;
410
+ target.sync.lastSyncedAt = binding.updatedAt;
411
+ target.sync.error = null;
412
+ target.sync.skippedCredentials = skippedCredentials;
413
+ this.registry.put(binding);
414
+ return {
415
+ bindingId: binding.id,
416
+ targetId: target.id,
417
+ personalProjectId: target.personalProjectId,
418
+ status: 'ready',
419
+ imported,
420
+ unchanged,
421
+ invalidated,
422
+ skippedCredentials,
423
+ nextCursor: cursor,
424
+ hasMore
425
+ };
426
+ } catch (error) {
427
+ target.status = 'error';
428
+ binding.updatedAt = this.now().toISOString();
429
+ target.sync.error = safeExternalSourceDiagnostic(error);
430
+ this.registry.put(binding);
431
+ throw error;
432
+ }
433
+ }
434
+
435
+ async #invalidateTarget(target) {
436
+ let invalidated = 0;
437
+ for (const item of Object.values(target.sync?.items ?? {})) {
438
+ invalidated += await this.#invalidateEntities(item.entityIds ?? [], target);
439
+ }
440
+ return invalidated;
441
+ }
442
+
443
+ async #invalidateEntities(ids, target) {
444
+ if (!ids.length) return 0;
445
+ if (typeof this.app.reviseKnowledgeItem !== 'function') {
446
+ throw new Error('FULI application cannot invalidate replaced external knowledge');
447
+ }
448
+ for (const itemId of ids) {
449
+ await this.app.reviseKnowledgeItem({
450
+ personalSpaceId: target.personalSpaceId,
451
+ personalProjectId: target.personalProjectId,
452
+ itemKind: 'entity',
453
+ itemId,
454
+ action: 'invalidate',
455
+ reason: 'The read-only external source was changed, removed, or disconnected.',
456
+ operationActor: 'agent'
457
+ });
458
+ }
459
+ return ids.length;
460
+ }
461
+
462
+ async #assertPersonalProjects(targets) {
463
+ const bySpace = new Map();
464
+ for (const target of targets) {
465
+ const selected = bySpace.get(target.personalSpaceId) ?? [];
466
+ selected.push(target);
467
+ bySpace.set(target.personalSpaceId, selected);
468
+ }
469
+ for (const [personalSpaceId, selected] of bySpace) {
470
+ const projects = await this.app.listPersonalProjects({ personalSpaceId });
471
+ const available = new Set(projects.map(({ project_id: id, id: fallback }) => id ?? fallback));
472
+ if (selected.some(({ personalProjectId }) => !available.has(personalProjectId))) {
473
+ throw new TypeError('External knowledge target must be an existing personal project');
474
+ }
475
+ }
476
+ }
477
+
478
+ #binding(id) {
479
+ const binding = this.registry.get(requiredString(id, 'binding id', 128));
480
+ if (!binding) throw new TypeError('External knowledge binding was not found');
481
+ return binding;
482
+ }
483
+
484
+ #connector(type) {
485
+ const connector = this.connectors.get(type);
486
+ if (!connector) throw new TypeError(`Unknown external knowledge connector: ${type}`);
487
+ return connector;
488
+ }
489
+
490
+ #context(binding, target = null) {
491
+ return {
492
+ config: binding.connectorConfig,
493
+ source: binding.source,
494
+ env: this.env,
495
+ bindingId: binding.id ?? null,
496
+ mode: target?.mode ?? commonBindingMode(binding.targets ?? [])
497
+ };
498
+ }
499
+ }
500
+
501
+ function publicBinding(binding) {
502
+ const output = structuredClone(binding);
503
+ const first = output.targets?.[0] ?? null;
504
+ return {
505
+ ...output,
506
+ // Keep the v1 aliases while clients migrate to target-aware bindings.
507
+ target: first ? {
508
+ personalSpaceId: first.personalSpaceId,
509
+ personalProjectId: first.personalProjectId
510
+ } : null,
511
+ mode: first?.mode ?? null,
512
+ sync: first?.sync ?? null
513
+ };
514
+ }
515
+
516
+ function bindingTargets(input, {
517
+ bindingId,
518
+ existingTargets = [],
519
+ defaultMode = 'hybrid'
520
+ }) {
521
+ const values = Array.isArray(input?.targets)
522
+ ? input.targets
523
+ : input?.target
524
+ ? [{ ...input.target, mode: input.mode ?? defaultMode }]
525
+ : null;
526
+ if (!values || !values.length) {
527
+ throw new TypeError('At least one target personal project is required');
528
+ }
529
+ if (values.length > MAX_TARGETS) {
530
+ throw new TypeError(`A connection can target at most ${MAX_TARGETS} personal projects`);
531
+ }
532
+
533
+ const existingByKey = new Map(existingTargets.map((target) => [targetKey(target), target]));
534
+ const reservedIds = new Set(existingTargets.map(({ id }) => id));
535
+ const seen = new Set();
536
+ return values.map((value, index) => {
537
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
538
+ throw new TypeError(`targets[${index}] must be an object`);
539
+ }
540
+ const target = {
541
+ personalSpaceId: requiredString(
542
+ value.personalSpaceId,
543
+ `targets[${index}].personalSpaceId`,
544
+ 256
545
+ ),
546
+ personalProjectId: requiredString(
547
+ value.personalProjectId,
548
+ `targets[${index}].personalProjectId`,
549
+ 256
550
+ )
551
+ };
552
+ const key = targetKey(target);
553
+ if (seen.has(key)) throw new TypeError('Target personal projects must be unique');
554
+ seen.add(key);
555
+ const previous = existingByKey.get(key);
556
+ const mode = requiredString(
557
+ value.mode ?? previous?.mode ?? defaultMode,
558
+ `targets[${index}].mode`,
559
+ 16
560
+ );
561
+ if (!MODES.has(mode)) throw new TypeError('mode must be hybrid, live, or mirror');
562
+ const id = previous?.id ?? targetId(bindingId, target, {
563
+ preferBindingId: existingTargets.length === 0 && index === 0,
564
+ reservedIds
565
+ });
566
+ reservedIds.add(id);
567
+ return {
568
+ id,
569
+ ...target,
570
+ mode,
571
+ status: previous?.status ?? 'ready',
572
+ sync: structuredClone(previous?.sync ?? emptySyncState())
573
+ };
574
+ });
575
+ }
576
+
577
+ function targetId(bindingId, target, { preferBindingId, reservedIds }) {
578
+ if (preferBindingId && !reservedIds.has(bindingId)) return bindingId;
579
+ const digest = createHash('sha256')
580
+ .update(`${bindingId}\0${target.personalSpaceId}\0${target.personalProjectId}`)
581
+ .digest('hex')
582
+ .slice(0, 24);
583
+ const id = `target:${digest}`;
584
+ if (reservedIds.has(id)) {
585
+ throw new TypeError('Target identifier collision');
586
+ }
587
+ return id;
588
+ }
589
+
590
+ function targetKey(target) {
591
+ return `${target.personalSpaceId}\0${target.personalProjectId}`;
592
+ }
593
+
594
+ function emptySyncState() {
595
+ return {
596
+ cursor: null,
597
+ lastSyncedAt: null,
598
+ error: null,
599
+ skippedCredentials: 0,
600
+ items: {}
601
+ };
602
+ }
603
+
604
+ function commonBindingMode(targets) {
605
+ const modes = new Set((targets ?? []).map(({ mode }) => mode));
606
+ if (modes.size === 1) return [...modes][0];
607
+ const requiresSync = [...modes].some((mode) => mode !== 'live');
608
+ const requiresRetrieve = [...modes].some((mode) => mode !== 'mirror');
609
+ if (requiresSync && requiresRetrieve) return 'hybrid';
610
+ if (requiresSync) return 'mirror';
611
+ return 'live';
612
+ }
613
+
614
+ function assertConnectorMethods(type, connector, targets) {
615
+ if (targets.some(({ mode }) => mode !== 'live') && typeof connector.sync !== 'function') {
616
+ throw new TypeError(`${type} does not support synchronization`);
617
+ }
618
+ if (targets.some(({ mode }) => mode !== 'mirror') && typeof connector.retrieve !== 'function') {
619
+ throw new TypeError(`${type} does not support live retrieval`);
620
+ }
621
+ }
622
+
623
+ function assertNegotiatedCapabilities(type, capabilities, targets) {
624
+ if (!Array.isArray(capabilities) || !capabilities.length) return;
625
+ if (targets.some(({ mode }) => mode !== 'live') && !capabilities.includes('sync')) {
626
+ throw new TypeError(`${type} does not advertise synchronization capability`);
627
+ }
628
+ if (targets.some(({ mode }) => mode !== 'mirror') && !capabilities.includes('retrieve')) {
629
+ throw new TypeError(`${type} does not advertise live retrieval capability`);
630
+ }
631
+ }
632
+
633
+ function selectedTargets(binding, input, { capability, requireOne = false }) {
634
+ const personalSpaceId = optionalString(input?.personalSpaceId, 'personalSpaceId', 256);
635
+ const personalProjectId = optionalString(input?.personalProjectId, 'personalProjectId', 256);
636
+ let targets = binding.targets.filter((target) =>
637
+ (!personalSpaceId || target.personalSpaceId === personalSpaceId) &&
638
+ (!personalProjectId || target.personalProjectId === personalProjectId)
639
+ );
640
+ if ((personalSpaceId || personalProjectId) && !targets.length) {
641
+ throw new TypeError('External knowledge binding is not assigned to this personal project');
642
+ }
643
+ targets = targets.filter(({ mode }) => capability === 'sync' ? mode !== 'live' : mode !== 'mirror');
644
+ if (!targets.length) {
645
+ throw new TypeError(capability === 'sync'
646
+ ? 'This binding is configured for live retrieval only'
647
+ : 'This binding is configured for synchronized graph search only');
648
+ }
649
+ if (requireOne && targets.length !== 1) {
650
+ throw new TypeError('personalSpaceId and personalProjectId are required for a multi-project binding');
651
+ }
652
+ return targets;
653
+ }
654
+
655
+ function aggregateSyncResults(bindingId, results) {
656
+ if (results.length === 1) {
657
+ const { targetId: _targetId, personalProjectId: _projectId, ...result } = results[0];
658
+ return result;
659
+ }
660
+ return {
661
+ bindingId,
662
+ status: results.every(({ status }) => status === 'ready') ? 'ready' : 'partial',
663
+ imported: sum(results, 'imported'),
664
+ unchanged: sum(results, 'unchanged'),
665
+ invalidated: sum(results, 'invalidated'),
666
+ skippedCredentials: sum(results, 'skippedCredentials'),
667
+ nextCursor: null,
668
+ hasMore: results.some(({ hasMore }) => hasMore),
669
+ targets: results
670
+ };
671
+ }
672
+
673
+ function sum(values, field) {
674
+ return values.reduce((total, value) => total + (value[field] ?? 0), 0);
675
+ }
676
+
677
+ function bindingForTarget(binding, target) {
678
+ return {
679
+ ...binding,
680
+ targetId: target.id,
681
+ target: {
682
+ personalSpaceId: target.personalSpaceId,
683
+ personalProjectId: target.personalProjectId
684
+ },
685
+ mode: target.mode,
686
+ sync: target.sync
687
+ };
688
+ }
689
+
690
+ function safeConfiguration(value, path = 'configuration') {
691
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
692
+ throw new TypeError(`${path} must be an object`);
693
+ }
694
+ const output = Object.create(null);
695
+ for (const [key, item] of Object.entries(value)) {
696
+ if (credentialField(key) && !credentialReferenceField(key)) {
697
+ throw new TypeError(`${path}.${key} cannot contain credentials; use an environment variable name`);
698
+ }
699
+ if (item === null || typeof item === 'boolean' || typeof item === 'number') {
700
+ output[key] = item;
701
+ } else if (typeof item === 'string') {
702
+ assertSafeConfigurationString(item, `${path}.${key}`);
703
+ output[key] = item;
704
+ } else if (Array.isArray(item)) {
705
+ output[key] = item.map((entry, index) =>
706
+ entry && typeof entry === 'object'
707
+ ? safeConfiguration(entry, `${path}.${key}[${index}]`)
708
+ : entry
709
+ );
710
+ } else if (item && typeof item === 'object') {
711
+ output[key] = safeConfiguration(item, `${path}.${key}`);
712
+ } else {
713
+ throw new TypeError(`${path}.${key} must be JSON-compatible`);
714
+ }
715
+ }
716
+ if (detectSensitiveContent(JSON.stringify(output)).restricted) {
717
+ throw new TypeError(`${path} cannot contain credentials; use an environment variable name`);
718
+ }
719
+ return output;
720
+ }
721
+
722
+ function assertSafeConfigurationString(value, path) {
723
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value)) return;
724
+ try {
725
+ const url = new URL(value);
726
+ if (url.username || url.password) {
727
+ throw new TypeError(`${path} cannot contain credentials; use an environment variable name`);
728
+ }
729
+ } catch (error) {
730
+ if (error instanceof TypeError && /cannot contain credentials/u.test(error.message)) {
731
+ throw error;
732
+ }
733
+ }
734
+ }
735
+
736
+ function credentialField(key) {
737
+ const normalized = normalizedConfigurationKey(key);
738
+ const withoutReferenceSuffix = normalized.replace(/(?:environment|env|names|name)$/u, '');
739
+ return CREDENTIAL_FIELDS.has(normalized) || CREDENTIAL_FIELDS.has(withoutReferenceSuffix);
740
+ }
741
+
742
+ function credentialReferenceField(key) {
743
+ return /(?:environment|env|names|name)$/u.test(normalizedConfigurationKey(key));
744
+ }
745
+
746
+ function normalizedConfigurationKey(key) {
747
+ return key.toLocaleLowerCase().replace(/[^a-z0-9]/gu, '');
748
+ }
749
+
750
+ function requiredString(value, label, maximum) {
751
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} is required`);
752
+ const normalized = value.trim();
753
+ if (normalized.length > maximum) throw new TypeError(`${label} is too long`);
754
+ return normalized;
755
+ }
756
+
757
+ function optionalString(value, label, maximum) {
758
+ if (value === undefined || value === null || value === '') return null;
759
+ return requiredString(value, label, maximum);
760
+ }
761
+
762
+ function positiveInteger(value, label, maximum) {
763
+ if (!Number.isInteger(value) || value <= 0 || value > maximum) {
764
+ throw new TypeError(`${label} must be a positive integer no greater than ${maximum}`);
765
+ }
766
+ return value;
767
+ }
768
+
769
+ function isCredentialDocumentError(error) {
770
+ return error instanceof TypeError &&
771
+ error.message === 'Connector item contains credentials and cannot be retrieved or stored';
772
+ }
773
+
774
+ function connectorItemId(value) {
775
+ const id = typeof value === 'string' ? value : value?.id;
776
+ if (typeof id !== 'string') return null;
777
+ const normalized = id.trim();
778
+ return normalized && normalized.length <= 512 ? normalized : null;
779
+ }
780
+
781
+ function connectorSyncPage(value, pageSize) {
782
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
783
+ throw new TypeError('Connector synchronization result must be an object');
784
+ }
785
+ const items = value.items ?? [];
786
+ const deleted = value.deleted ?? [];
787
+ if (!Array.isArray(items) || !Array.isArray(deleted)) {
788
+ throw new TypeError('Connector synchronization items and deleted must be arrays');
789
+ }
790
+ if (items.length > pageSize) {
791
+ throw new TypeError(`Connector returned more than ${pageSize} items`);
792
+ }
793
+ if (deleted.length > pageSize) {
794
+ throw new TypeError(`Connector returned more than ${pageSize} deletions`);
795
+ }
796
+ if (value.hasMore !== undefined && typeof value.hasMore !== 'boolean') {
797
+ throw new TypeError('Connector hasMore must be a boolean');
798
+ }
799
+ const nextCursor = value.nextCursor ?? null;
800
+ if (nextCursor !== null &&
801
+ (typeof nextCursor !== 'string' || nextCursor.length > 64_000)) {
802
+ throw new TypeError('Connector nextCursor must be a string no longer than 64000 characters');
803
+ }
804
+ return {
805
+ ...value,
806
+ items,
807
+ deleted,
808
+ hasMore: value.hasMore === true,
809
+ nextCursor
810
+ };
811
+ }