fuli-context 0.4.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.en.md +5 -0
  2. package/README.md +411 -173
  3. package/README.zh-CN.md +448 -0
  4. package/compose.graphiti.yml +6 -6
  5. package/dist/web/assets/AboutPage-DXh7Fwxt.css +1 -0
  6. package/dist/web/assets/AboutPage-DfnZo7Lo.js +1 -0
  7. package/dist/web/assets/ConnectionsPage-BloaSKmt.js +1 -0
  8. package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +1 -0
  9. package/dist/web/assets/KnowledgeInspector-Bjhdu2mf.css +1 -0
  10. package/dist/web/assets/KnowledgeInspector-QvNpv0_k.js +1 -0
  11. package/dist/web/assets/KnowledgeOrganizerPage-Cx1w1jb2.css +1 -0
  12. package/dist/web/assets/KnowledgeOrganizerPage-DKq1jVD5.js +1 -0
  13. package/dist/web/assets/KnowledgePage-BWL0jT01.js +1 -0
  14. package/dist/web/assets/KnowledgeWorkspace-Czq6aZ7b.js +4 -0
  15. package/dist/web/assets/OverviewPage-5sJoO1Gh.js +1 -0
  16. package/dist/web/assets/{PersonalProfilePage-S8ZcG2LU.css → PersonalProfilePage-CdmEzyvZ.css} +1 -1
  17. package/dist/web/assets/PersonalProfilePage-tVChM9ik.js +1 -0
  18. package/dist/web/assets/PersonalProjectsPage-BPFrup4V.js +1 -0
  19. package/dist/web/assets/PublicProjectsPage-BPJSeREN.js +1 -0
  20. package/dist/web/assets/ReviewPage-B7EN817i.js +1 -0
  21. package/dist/web/assets/SearchableSelect-ClLN0rIb.js +1 -0
  22. package/dist/web/assets/SettingsPage-C6ouU0sR.css +1 -0
  23. package/dist/web/assets/SettingsPage-Chd_B3eH.js +1 -0
  24. package/dist/web/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  25. package/dist/web/assets/console-CbDsKrsO.js +1 -0
  26. package/dist/web/assets/fuli-logo-Bu9WBuQe.js +1 -0
  27. package/dist/web/assets/fuli-logo-ER1wQ8Kk.png +0 -0
  28. package/dist/web/assets/i18n-BQcns1PH.js +5 -0
  29. package/dist/web/assets/identity-B8K4VYg6.js +1 -0
  30. package/dist/web/assets/index-CB8Xmsc2.js +2 -0
  31. package/dist/web/assets/index-CZeuPh-U.css +1 -0
  32. package/dist/web/assets/model-eF52GgPF.js +1 -0
  33. package/dist/web/assets/paths-hwz-Ac1g.js +1 -0
  34. package/dist/web/assets/useMinimumLoadingDisplay-BpCyBeko.css +1 -0
  35. package/dist/web/assets/useMinimumLoadingDisplay-wCgGDmHg.js +1 -0
  36. package/dist/web/assets/vue-router-BsEp_XwI.js +1 -0
  37. package/dist/web/index.html +9 -6
  38. package/docs/external-knowledge-architecture.md +279 -0
  39. package/examples/external-knowledge/markdown-folder.mjs +235 -0
  40. package/graph-provider/fuli_graph/app.py +57 -1
  41. package/graph-provider/fuli_graph/graph_models.py +3 -0
  42. package/graph-provider/fuli_graph/graph_query.py +62 -16
  43. package/graph-provider/fuli_graph/knowledge_review.py +612 -0
  44. package/graph-provider/fuli_graph/knowledge_review_models.py +127 -0
  45. package/graph-provider/fuli_graph/knowledge_search.py +51 -4
  46. package/graph-provider/fuli_graph/models.py +19 -0
  47. package/graph-provider/fuli_graph/runtime.py +15 -0
  48. package/graph-provider/fuli_graph/store_knowledge.py +2 -1
  49. package/graph-provider/pyproject.toml +1 -1
  50. package/npm-shrinkwrap.json +5 -407
  51. package/package.json +7 -4
  52. package/skills/capturing-session-knowledge/SKILL.md +24 -4
  53. package/skills/flreview/SKILL.md +73 -0
  54. package/skills/flreview/agents/openai.yaml +6 -0
  55. package/src/agent-tools/graph-definitions.js +79 -4
  56. package/src/agent-tools/graph-handlers.js +12 -0
  57. package/src/cli/command-registry.js +3 -3
  58. package/src/cli/local-runtime-command.js +60 -23
  59. package/src/cli/local-runtime-options.js +16 -6
  60. package/src/cli/setup-agent-selection.js +12 -12
  61. package/src/cli/setup-command.js +41 -18
  62. package/src/cli/shell-argument.js +10 -0
  63. package/src/cli/uninstall-command.js +17 -15
  64. package/src/cli/update-command.js +38 -30
  65. package/src/external-knowledge/catalog.js +61 -0
  66. package/src/external-knowledge/conflict-policy.js +58 -0
  67. package/src/external-knowledge/connected-search.js +166 -0
  68. package/src/external-knowledge/connectors/custom.js +133 -0
  69. package/src/external-knowledge/connectors/feishu.js +461 -0
  70. package/src/external-knowledge/connectors/mcp.js +438 -0
  71. package/src/external-knowledge/connectors/notion.js +307 -0
  72. package/src/external-knowledge/connectors/retrieval-api.js +222 -0
  73. package/src/external-knowledge/document-mapping.js +151 -0
  74. package/src/external-knowledge/graph-projection.js +17 -0
  75. package/src/external-knowledge/index.js +8 -0
  76. package/src/external-knowledge/registry.js +91 -0
  77. package/src/external-knowledge/runtime.js +68 -0
  78. package/src/external-knowledge/safe-diagnostic.js +14 -0
  79. package/src/external-knowledge/service.js +811 -0
  80. package/src/graphiti/agent-knowledge-workflows.js +7 -2
  81. package/src/graphiti/federated-application.js +66 -41
  82. package/src/graphiti/knowledge-item-role.js +21 -0
  83. package/src/graphiti/knowledge-review.js +43 -0
  84. package/src/graphiti/project-profile-mapping.js +32 -0
  85. package/src/graphiti/provider-client.js +22 -1
  86. package/src/graphiti/source-marker.js +54 -3
  87. package/src/graphiti/task-knowledge-recall.js +250 -0
  88. package/src/http/api-router.js +23 -108
  89. package/src/http/external-knowledge-api-router.js +104 -0
  90. package/src/http/graph-api-router.js +12 -1
  91. package/src/http/request-policy.js +38 -3
  92. package/src/local-runtime/lifecycle.js +37 -16
  93. package/src/mcp/create-mcp-server.js +1 -0
  94. package/src/mcp/instructions.js +7 -7
  95. package/src/mcp/tool-annotations.js +6 -0
  96. package/src/mcp/tool-result.js +1 -0
  97. package/src/server/application-lifecycle.js +2 -11
  98. package/src/server/blocked-ports.js +11 -0
  99. package/src/server/lan-access.js +52 -0
  100. package/src/server.js +103 -25
  101. package/src/setup/agent-installation-status.js +2 -1
  102. package/src/setup/agents.js +2 -1
  103. package/src/setup/claude-code-config.js +2 -1
  104. package/src/setup/codex-bootstrap.js +12 -7
  105. package/src/setup/container-runtime.js +25 -21
  106. package/src/setup/graph-runtime.js +219 -58
  107. package/src/setup/node-runtime.js +3 -3
  108. package/src/setup/options.js +2 -4
  109. package/src/setup/paths.js +18 -3
  110. package/src/setup/review-skill-adapters.js +49 -0
  111. package/src/setup/setup.js +26 -2
  112. package/src/setup/uninstall.js +4 -2
  113. package/src/system/resource-monitor.js +317 -0
  114. package/src/system/runtime-settings.js +131 -0
  115. package/src/system/system-service.js +119 -0
  116. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +0 -1
  117. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +0 -1
  118. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +0 -1
  119. package/dist/web/assets/KnowledgeOrganizerPage-CJCNI2Wd.css +0 -1
  120. package/dist/web/assets/KnowledgeOrganizerPage-PXH-TlGt.js +0 -1
  121. package/dist/web/assets/KnowledgePage-CNQXd15y.js +0 -1
  122. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +0 -4
  123. package/dist/web/assets/OverviewPage-ul7g7HXh.js +0 -1
  124. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +0 -1
  125. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +0 -1
  126. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +0 -1
  127. package/dist/web/assets/ReviewPage-7MDDUDgA.js +0 -1
  128. package/dist/web/assets/console-B_Ena2DS.js +0 -5
  129. package/dist/web/assets/fuli-logo-DGuyqchQ.png +0 -0
  130. package/dist/web/assets/identity-CjBwYSMX.js +0 -1
  131. package/dist/web/assets/index-BQRkOwPx.js +0 -2
  132. package/dist/web/assets/index-CpLDx3of.css +0 -1
  133. package/dist/web/assets/model-C-5RVIJ2.js +0 -1
  134. package/dist/web/assets/paths-BkULqmGI.js +0 -1
  135. package/dist/web/assets/vue-router-DnD3hUm7.js +0 -1
  136. package/src/agent/candidate-read-service.js +0 -53
  137. package/src/agent/context-pack-service.js +0 -83
  138. package/src/agent/public-read-policy.js +0 -39
  139. package/src/agent/public-read-service.js +0 -69
  140. package/src/agent/read-projection.js +0 -107
  141. package/src/agent/search-service.js +0 -61
  142. package/src/agent/space-boundary.js +0 -33
  143. package/src/app/bootstrap.js +0 -8
  144. package/src/app/create-application.js +0 -143
  145. package/src/app/state-service.js +0 -38
  146. package/src/candidates.js +0 -78
  147. package/src/classifier.js +0 -63
  148. package/src/cli/command-arguments.js +0 -14
  149. package/src/context-pack.js +0 -206
  150. package/src/extractor.js +0 -77
  151. package/src/fact-history.js +0 -39
  152. package/src/facts/fact-service.js +0 -89
  153. package/src/http/bounded-json-array.js +0 -19
  154. package/src/http/lens-route.js +0 -50
  155. package/src/ingestion.js +0 -161
  156. package/src/lens/lens-correction-service.js +0 -81
  157. package/src/lens/lens-fact-writer.js +0 -85
  158. package/src/lens/lens-input.js +0 -62
  159. package/src/lens/lens-query-projection.js +0 -87
  160. package/src/lens/lens-query-ranking.js +0 -62
  161. package/src/lens/lens-query.js +0 -143
  162. package/src/lens/lens-resource-projection.js +0 -93
  163. package/src/lens/lens-resource-selection.js +0 -45
  164. package/src/lens/lens-resource-service.js +0 -103
  165. package/src/lens/lens-search-query.js +0 -122
  166. package/src/lens/lens-service.js +0 -183
  167. package/src/models.js +0 -44
  168. package/src/observer.js +0 -83
  169. package/src/project-rules.js +0 -33
  170. package/src/publication/outbox-service.js +0 -46
  171. package/src/publication/publication-envelope-schema.js +0 -75
  172. package/src/publication/publication-envelope.js +0 -37
  173. package/src/publication/publication-policy.js +0 -82
  174. package/src/publication/publication-service.js +0 -48
  175. package/src/router.js +0 -58
  176. package/src/runtime-options.js +0 -138
  177. package/src/security/episode-visibility.js +0 -14
  178. package/src/security/fact-visibility.js +0 -15
  179. package/src/security/source-metadata-policy.js +0 -8
  180. package/src/storage/episode-evidence-preview.js +0 -100
  181. package/src/storage/fact-search-order.js +0 -18
  182. package/src/storage/file/episode-evidence.js +0 -37
  183. package/src/storage/file/episode-index.js +0 -59
  184. package/src/storage/file/fact-search.js +0 -60
  185. package/src/storage/file-store.js +0 -437
  186. package/src/storage/migrate.js +0 -128
  187. package/src/storage/migrations/001-initial.sql +0 -83
  188. package/src/storage/migrations/002-storage-integrity.sql +0 -113
  189. package/src/storage/record-validation.js +0 -46
  190. package/src/storage/snapshot-defaults.js +0 -54
  191. package/src/storage/sqlite/candidates-repository.js +0 -84
  192. package/src/storage/sqlite/episode-evidence-repository.js +0 -154
  193. package/src/storage/sqlite/episode-privacy-sql.js +0 -33
  194. package/src/storage/sqlite/episodes-repository.js +0 -52
  195. package/src/storage/sqlite/fact-search-repository.js +0 -162
  196. package/src/storage/sqlite/facts-repository.js +0 -146
  197. package/src/storage/sqlite/imports-repository.js +0 -39
  198. package/src/storage/sqlite/mapper.js +0 -84
  199. package/src/storage/sqlite/open-database.js +0 -15
  200. package/src/storage/sqlite/outbox-repository.js +0 -103
  201. package/src/storage/sqlite/snapshot-repository.js +0 -69
  202. package/src/storage/sqlite/spaces-repository.js +0 -52
  203. package/src/storage/sqlite/subscriptions-repository.js +0 -54
  204. package/src/storage/sqlite-store.js +0 -159
  205. package/src/storage/stable-json.js +0 -24
  206. package/src/storage/store-port.js +0 -20
  207. package/src/store.js +0 -2
@@ -0,0 +1,250 @@
1
+ import { createFuliSourceMarker } from './source-marker.js';
2
+ import { knowledgeItemRole } from './knowledge-item-role.js';
3
+ import { detectSensitiveContent } from '../security/sensitive-content.js';
4
+
5
+ const MAX_QUERIES = 4;
6
+ const MAX_ITEMS_PER_KIND = 3;
7
+ const MAX_ITEM_TEXT = 1000;
8
+
9
+ const RECALL_INTENTS = Object.freeze([
10
+ {
11
+ category: 'release_delivery',
12
+ pattern: /(?:发布|发版|上线|部署|推送|再推|release|publish|push|deploy|ship|rollout|commit|github|\bgit\b)/iu,
13
+ queries: [
14
+ '发布 发版 版本 release publish runbook',
15
+ '推送 提交 push commit GitHub Connector runbook'
16
+ ]
17
+ },
18
+ {
19
+ category: 'access_authentication',
20
+ pattern: /(?:认证|登录|凭据|身份|令牌|密钥|credential|authentication|authorization|login|token|ssh)/iu,
21
+ queries: ['认证 登录 凭据 身份 credential authentication SSH token runbook']
22
+ },
23
+ {
24
+ category: 'artifact_location',
25
+ pattern: /(?:网址|地址|路由|端点|在哪里|在哪儿|哪里|(?:文档|文件).{0,12}(?:位置|路径|在哪)|(?:找|查).{0,12}(?:文档|文件)|\burl\b|route|endpoint|document location|file path|where)/iu,
26
+ queries: ['地址 URL 路由 route endpoint 部署 deployment 文档 document']
27
+ },
28
+ {
29
+ category: 'requirements_architecture_decision',
30
+ pattern: /(?:需求|约束|架构|设计决定|决策|原因|取舍|requirement|constraint|architecture|decision|rationale|tradeoff)/iu,
31
+ queries: ['需求 约束 架构 决策 原因 requirement constraint architecture decision rationale']
32
+ },
33
+ {
34
+ category: 'runbook_method',
35
+ pattern: /(?:方法|方式|流程|步骤|怎么做|如何做|runbook|workflow|procedure|how to)/iu,
36
+ queries: ['方法 方式 流程 步骤 runbook workflow procedure']
37
+ },
38
+ {
39
+ category: 'prior_context',
40
+ pattern: /(?:之前|以前|上次|已经存|保存过|记得|照旧|同样|还是用|不用再|先前|previous|earlier|remember|same as|again)/iu,
41
+ queries: ['先前 方法 规则 决策 runbook previous method decision']
42
+ }
43
+ ]);
44
+
45
+ export const TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE = Object.freeze({
46
+ before_reasking:
47
+ 'Before asking the user to repeat a stable project fact or method, inspect task_knowledge_recall and search Fuli when needed.',
48
+ focused_queries:
49
+ 'Use one to four focused action, artifact, target-system, or identifier queries; never use the full conversational request as the only query.',
50
+ scope:
51
+ 'Search only the exact active personal project, its explicitly inheritable knowledge, and the bounded personal-global profile.'
52
+ });
53
+
54
+ export function planTaskKnowledgeRecall(taskPrompt) {
55
+ if (typeof taskPrompt !== 'string' || !taskPrompt.trim()) {
56
+ return { status: 'not_requested', trigger_categories: [], queries: [] };
57
+ }
58
+ const prompt = singleLine(taskPrompt).slice(0, 8192);
59
+ if (detectSensitiveContent(prompt).restricted) {
60
+ return {
61
+ status: 'suppressed_sensitive',
62
+ trigger_categories: [],
63
+ queries: []
64
+ };
65
+ }
66
+ const matched = RECALL_INTENTS.filter(({ pattern }) => pattern.test(prompt));
67
+ if (!matched.length) {
68
+ return { status: 'not_needed', trigger_categories: [], queries: [] };
69
+ }
70
+ const queries = unique(matched.flatMap(({ queries: values }) => values));
71
+ const distinctive = distinctivePromptQuery(prompt);
72
+ if (distinctive) queries.push(distinctive);
73
+ return {
74
+ status: 'planned',
75
+ trigger_categories: matched.map(({ category }) => category),
76
+ queries: unique(queries).slice(0, MAX_QUERIES)
77
+ };
78
+ }
79
+
80
+ export async function recallTaskKnowledge(application, resolution, taskPrompt) {
81
+ const plan = planTaskKnowledgeRecall(taskPrompt);
82
+ if (plan.status !== 'planned') return recallWithoutSearch(plan.status, plan);
83
+ if (!resolution.personalProjectId) {
84
+ return {
85
+ status: 'project_unresolved',
86
+ trigger_categories: plan.trigger_categories,
87
+ query_count: plan.queries.length,
88
+ guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
89
+ facts: [],
90
+ entities: []
91
+ };
92
+ }
93
+
94
+ const settlements = await Promise.allSettled(plan.queries.map((query) =>
95
+ application.searchKnowledge({
96
+ personalSpaceId: application.config.personal.spaceId,
97
+ personalProjectId: resolution.personalProjectId,
98
+ query,
99
+ limit: 6,
100
+ includeHistorical: false,
101
+ includePending: false,
102
+ agentInvocation: true,
103
+ agentToolName: 'automatic_task_knowledge_recall'
104
+ })
105
+ ));
106
+ const results = settlements
107
+ .filter(({ status }) => status === 'fulfilled')
108
+ .map(({ value }) => value);
109
+ const failedQueryCount = settlements.length - results.length;
110
+ const facts = rankedUnique(
111
+ results.flatMap(({ facts = [] }) => facts),
112
+ 'relationship'
113
+ ).slice(0, MAX_ITEMS_PER_KIND).map(compactFact);
114
+ const entities = rankedUnique(
115
+ results.flatMap(({ entities = [] }) => entities),
116
+ 'entity'
117
+ ).slice(0, MAX_ITEMS_PER_KIND).map(compactEntity);
118
+ const sourceMarker = createFuliSourceMarker({
119
+ consoleUrl: application.consoleUrl,
120
+ facts,
121
+ entities,
122
+ projectScopePriority: 'strict'
123
+ });
124
+ const searched = results.length > 0;
125
+ return {
126
+ status: facts.length || entities.length
127
+ ? 'matched'
128
+ : failedQueryCount === settlements.length ? 'unavailable' : 'no_match',
129
+ partial: failedQueryCount > 0,
130
+ failed_query_count: failedQueryCount,
131
+ trigger_categories: plan.trigger_categories,
132
+ query_count: plan.queries.length,
133
+ guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
134
+ ...(searched ? {
135
+ sourceMarker,
136
+ noMatchSourceMarker: createFuliSourceMarker({
137
+ consoleUrl: application.consoleUrl
138
+ })
139
+ } : {}),
140
+ facts,
141
+ entities
142
+ };
143
+ }
144
+
145
+ function recallWithoutSearch(status, plan) {
146
+ return {
147
+ status,
148
+ trigger_categories: plan.trigger_categories,
149
+ query_count: plan.queries.length,
150
+ guidance: TASK_KNOWLEDGE_RETRIEVAL_GUIDANCE,
151
+ facts: [],
152
+ entities: []
153
+ };
154
+ }
155
+
156
+ function rankedUnique(items, itemKind) {
157
+ const selected = new Map();
158
+ for (const item of items) {
159
+ const key = `${itemKind}:${item.id}`;
160
+ const current = selected.get(key);
161
+ if (!current || compareRecallItems(item, current) < 0) selected.set(key, item);
162
+ }
163
+ return [...selected.values()].sort(compareRecallItems);
164
+ }
165
+
166
+ function compareRecallItems(left, right) {
167
+ const leftLocal = left.defined_project_id ? 1 : 0;
168
+ const rightLocal = right.defined_project_id ? 1 : 0;
169
+ if (leftLocal !== rightLocal) return rightLocal - leftLocal;
170
+ const leftDistance = Number(left.scope_distance ?? 0);
171
+ const rightDistance = Number(right.scope_distance ?? 0);
172
+ if (leftDistance !== rightDistance) return leftDistance - rightDistance;
173
+ const leftSupporting = knowledgeItemRole(left) === 'supporting' ? 1 : 0;
174
+ const rightSupporting = knowledgeItemRole(right) === 'supporting' ? 1 : 0;
175
+ if (leftSupporting !== rightSupporting) return leftSupporting - rightSupporting;
176
+ return Number(right.score ?? 0) - Number(left.score ?? 0);
177
+ }
178
+
179
+ function compactFact(fact) {
180
+ return compactItem(fact, {
181
+ source_entity: fact.source_entity,
182
+ target_entity: fact.target_entity,
183
+ relationship: fact.relationship,
184
+ fact: boundedText(fact.fact)
185
+ });
186
+ }
187
+
188
+ function compactEntity(entity) {
189
+ return compactItem(entity, {
190
+ name: entity.name,
191
+ type: entity.type,
192
+ summary: boundedText(entity.summary)
193
+ });
194
+ }
195
+
196
+ function compactItem(item, content) {
197
+ return {
198
+ id: item.id,
199
+ space_id: item.space_id ?? item.spaceId,
200
+ scope: item.scope,
201
+ key: item.key ?? null,
202
+ defined_project_id: item.defined_project_id ?? null,
203
+ inherited_from_project_id: item.inherited_from_project_id ?? null,
204
+ scope_distance: item.scope_distance ?? 0,
205
+ confirmation_status: item.confirmation_status ?? null,
206
+ source_uris: item.source_uris ?? [],
207
+ score: item.score ?? null,
208
+ ...content
209
+ };
210
+ }
211
+
212
+ function distinctivePromptQuery(prompt) {
213
+ const quoted = [...prompt.matchAll(/[“”"']([^“”"']{2,80})[“”"']/gu)]
214
+ .map((match) => match[1]);
215
+ const versions = prompt.match(
216
+ /\bv?\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?\b/gu
217
+ ) ?? [];
218
+ const identifiers = prompt.match(
219
+ /\b[A-Za-z][A-Za-z0-9]{0,63}[_./-][A-Za-z0-9_.\/-]{1,127}\b/gu
220
+ ) ?? [];
221
+ const urls = [...prompt.matchAll(/https?:\/\/[^\s<>()]+/gu)]
222
+ .map((match) => safeUrlSearchTerm(match[0]))
223
+ .filter(Boolean);
224
+ return unique([...quoted, ...versions, ...identifiers, ...urls])
225
+ .slice(0, 4)
226
+ .join(' ')
227
+ .slice(0, 180);
228
+ }
229
+
230
+ function safeUrlSearchTerm(value) {
231
+ try {
232
+ const url = new URL(value);
233
+ if (url.username || url.password) return null;
234
+ return `${url.hostname}${url.pathname}`.slice(0, 120);
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ function unique(values) {
241
+ return [...new Set(values.filter(Boolean))];
242
+ }
243
+
244
+ function singleLine(value) {
245
+ return String(value).replace(/[\r\n\t]+/gu, ' ').replace(/\s+/gu, ' ').trim();
246
+ }
247
+
248
+ function boundedText(value) {
249
+ return singleLine(value ?? '').slice(0, MAX_ITEM_TEXT);
250
+ }
@@ -1,8 +1,15 @@
1
1
  import { readJson, sendJson } from './response.js';
2
- import { handleLensRequest } from './lens-route.js';
2
+ import { handleExternalKnowledgeApiRequest } from './external-knowledge-api-router.js';
3
3
  import { handleGraphApiRequest } from './graph-api-router.js';
4
4
 
5
- export async function handleApiRequest({ request, response, app }) {
5
+ export async function handleApiRequest({
6
+ request,
7
+ response,
8
+ app,
9
+ system = null,
10
+ externalKnowledge = null,
11
+ connectedKnowledge = null
12
+ }) {
6
13
  const url = new URL(request.url, 'http://127.0.0.1');
7
14
 
8
15
  if (url.pathname === '/api/health' && request.method === 'GET') {
@@ -14,120 +21,28 @@ export async function handleApiRequest({ request, response, app }) {
14
21
  return true;
15
22
  }
16
23
 
17
- if (app.graphiti) {
18
- return handleGraphApiRequest({ request, response, url, app });
19
- }
20
-
21
- if (handleLensRequest({ request, response, url, app })) return true;
22
-
23
- if (url.pathname === '/api/state' && request.method === 'GET') {
24
- sendJson(response, 200, app.state());
25
- return true;
26
- }
27
-
28
- if (url.pathname === '/api/spaces' && request.method === 'POST') {
29
- const body = await readJson(request);
30
- const space = app.createSpace(body.name, body.kind, body.description ?? null);
31
- sendJson(response, 200, { space });
32
- return true;
33
- }
34
-
35
- if (url.pathname === '/api/bootstrap' && request.method === 'POST') {
36
- sendJson(response, 200, app.bootstrap());
37
- return true;
38
- }
39
-
40
- if (url.pathname === '/api/subscriptions' && request.method === 'POST') {
41
- const body = await readJson(request);
42
- const subscription = app.subscribe(
43
- body.personalSpaceId,
44
- body.spaceId,
45
- body.mode ?? 'latest'
46
- );
47
- sendJson(response, 200, { subscription });
48
- return true;
49
- }
50
-
51
- if (url.pathname === '/api/remember' && request.method === 'POST') {
52
- const body = await readJson(request);
53
- const result = app.remember({
54
- personalSpaceId: app.requireSpaceId({
55
- id: body.personalSpaceId,
56
- name: body.personalSpaceName,
57
- label: 'Personal space'
58
- }),
59
- targetSpaceId: optionalSpaceId(app, {
60
- id: body.targetSpaceId,
61
- name: body.targetSpaceName,
62
- label: 'Target space'
63
- }),
64
- sourceKind: body.sourceKind ?? 'agent',
65
- body: body.body,
66
- sourceUri: body.sourceUri ?? null
67
- });
68
- sendJson(response, 200, result);
69
- return true;
70
- }
71
-
72
- if (url.pathname === '/api/observe/git-diff' && request.method === 'POST') {
73
- const body = await readJson(request);
74
- const result = app.observe({
75
- personalSpaceId: body.personalSpaceId,
76
- targetSpaceId: body.targetSpaceId || null,
77
- cwd: body.cwd ?? process.cwd()
78
- });
79
- sendJson(response, 200, result);
80
- return true;
81
- }
82
-
83
- const candidateDecisionMatch = url.pathname.match(/^\/api\/candidates\/([^/]+)\/decision$/);
84
- if (candidateDecisionMatch && request.method === 'POST') {
85
- const body = await readJson(request);
86
- const candidate = app.decideCandidate(candidateDecisionMatch[1], body.decision);
87
- sendJson(response, 200, { candidate });
24
+ if (system && url.pathname === '/api/system/settings' && request.method === 'GET') {
25
+ sendJson(response, 200, system.getSettings());
88
26
  return true;
89
27
  }
90
28
 
91
- if (url.pathname === '/api/search' && request.method === 'GET') {
92
- const result = app.search({
93
- personalSpaceId: requiredSpaceId(app, {
94
- id: url.searchParams.get('personalSpaceId'),
95
- name: url.searchParams.get('personalSpaceName'),
96
- label: 'Personal space'
97
- }),
98
- query: url.searchParams.get('q') ?? '',
99
- includeHistorical: url.searchParams.get('historical') === 'true'
100
- });
101
- sendJson(response, 200, result);
29
+ if (system && url.pathname === '/api/system/settings' && request.method === 'PUT') {
30
+ sendJson(response, 200, system.updateSettings(await readJson(request)));
102
31
  return true;
103
32
  }
104
33
 
105
- if (url.pathname === '/api/context-pack' && request.method === 'GET') {
106
- const result = app.contextPack({
107
- personalSpaceId: requiredSpaceId(app, {
108
- id: url.searchParams.get('personalSpaceId'),
109
- name: url.searchParams.get('personalSpaceName'),
110
- label: 'Personal space'
111
- }),
112
- spaceId: requiredSpaceId(app, {
113
- id: url.searchParams.get('spaceId'),
114
- name: url.searchParams.get('spaceName'),
115
- label: 'Space'
116
- }),
117
- query: url.searchParams.get('q') ?? ''
118
- });
119
- sendJson(response, 200, result);
34
+ if (system && url.pathname === '/api/system/resources' && request.method === 'GET') {
35
+ sendJson(response, 200, await system.resources());
120
36
  return true;
121
37
  }
122
38
 
123
- return false;
124
- }
125
-
126
- function requiredSpaceId(app, input) {
127
- return app.requireSpaceId(input);
128
- }
39
+ if (await handleExternalKnowledgeApiRequest({
40
+ request,
41
+ response,
42
+ url,
43
+ externalKnowledge,
44
+ connectedKnowledge
45
+ })) return true;
129
46
 
130
- function optionalSpaceId(app, input) {
131
- if (!input.id && !input.name) return null;
132
- return requiredSpaceId(app, input);
47
+ return handleGraphApiRequest({ request, response, url, app });
133
48
  }
@@ -0,0 +1,104 @@
1
+ import { readJson, sendJson } from './response.js';
2
+
3
+ export async function handleExternalKnowledgeApiRequest({
4
+ request,
5
+ response,
6
+ url,
7
+ externalKnowledge,
8
+ connectedKnowledge
9
+ }) {
10
+ if (!externalKnowledge && !connectedKnowledge) return false;
11
+
12
+ if (externalKnowledge &&
13
+ url.pathname === '/api/external-knowledge/connectors' &&
14
+ request.method === 'GET') {
15
+ sendJson(response, 200, externalKnowledge.listConnectorTypes());
16
+ return true;
17
+ }
18
+ if (externalKnowledge &&
19
+ url.pathname === '/api/external-knowledge/discover' &&
20
+ request.method === 'POST') {
21
+ sendJson(response, 200, await externalKnowledge.discover(await readJson(request)));
22
+ return true;
23
+ }
24
+ if (externalKnowledge &&
25
+ url.pathname === '/api/external-knowledge/bindings' &&
26
+ request.method === 'GET') {
27
+ sendJson(response, 200, await externalKnowledge.listBindings());
28
+ return true;
29
+ }
30
+ if (externalKnowledge &&
31
+ url.pathname === '/api/external-knowledge/bindings' &&
32
+ request.method === 'POST') {
33
+ sendJson(response, 201, await externalKnowledge.createBinding(await readJson(request)));
34
+ return true;
35
+ }
36
+
37
+ const action = url.pathname.match(
38
+ /^\/api\/external-knowledge\/bindings\/([^/]+)\/(check|sync|retrieve)$/
39
+ );
40
+ if (externalKnowledge && action && request.method === 'POST') {
41
+ const id = decodeURIComponent(action[1]);
42
+ const body = await readJson(request);
43
+ const result = action[2] === 'check'
44
+ ? await externalKnowledge.checkBinding(id)
45
+ : action[2] === 'sync'
46
+ ? await externalKnowledge.syncBinding(id, body)
47
+ : await externalKnowledge.retrieveBinding(id, body);
48
+ sendJson(response, 200, result);
49
+ return true;
50
+ }
51
+
52
+ const targets = url.pathname.match(
53
+ /^\/api\/external-knowledge\/bindings\/([^/]+)\/targets$/
54
+ );
55
+ if (externalKnowledge && targets && request.method === 'PATCH') {
56
+ sendJson(
57
+ response,
58
+ 200,
59
+ await externalKnowledge.updateBindingTargets(
60
+ decodeURIComponent(targets[1]),
61
+ await readJson(request)
62
+ )
63
+ );
64
+ return true;
65
+ }
66
+
67
+ const binding = url.pathname.match(
68
+ /^\/api\/external-knowledge\/bindings\/([^/]+)$/
69
+ );
70
+ if (externalKnowledge && binding && request.method === 'DELETE') {
71
+ sendJson(
72
+ response,
73
+ 200,
74
+ await externalKnowledge.deleteBinding(decodeURIComponent(binding[1]))
75
+ );
76
+ return true;
77
+ }
78
+
79
+ if (connectedKnowledge &&
80
+ url.pathname === '/api/external-knowledge/conflict-policy' &&
81
+ request.method === 'GET') {
82
+ sendJson(response, 200, connectedKnowledge.getConflictPolicy({
83
+ personalProjectId: url.searchParams.get('personalProjectId')
84
+ }));
85
+ return true;
86
+ }
87
+ if (connectedKnowledge &&
88
+ url.pathname === '/api/external-knowledge/conflict-policy' &&
89
+ request.method === 'PATCH') {
90
+ sendJson(
91
+ response,
92
+ 200,
93
+ await connectedKnowledge.updateConflictPolicy(await readJson(request))
94
+ );
95
+ return true;
96
+ }
97
+ if (connectedKnowledge &&
98
+ url.pathname === '/api/connected-knowledge/search' &&
99
+ request.method === 'POST') {
100
+ sendJson(response, 200, await connectedKnowledge.query(await readJson(request)));
101
+ return true;
102
+ }
103
+ return false;
104
+ }
@@ -44,7 +44,8 @@ export async function handleGraphApiRequest({ request, response, url, app }) {
44
44
  spaceId: url.searchParams.get('spaceId'),
45
45
  providerUrl: url.searchParams.get('providerUrl'),
46
46
  personalProjectId: url.searchParams.get('personalProjectId'),
47
- limit: numberParam(url, 'limit', 500)
47
+ limit: numberParam(url, 'limit', 500),
48
+ offset: nonNegativeNumberParam(url, 'offset', null)
48
49
  }));
49
50
  return true;
50
51
  }
@@ -255,3 +256,13 @@ function numberParam(url, name, fallback) {
255
256
  }
256
257
  return value;
257
258
  }
259
+
260
+ function nonNegativeNumberParam(url, name, fallback) {
261
+ const raw = url.searchParams.get(name);
262
+ if (raw === null) return fallback;
263
+ const value = Number(raw);
264
+ if (!Number.isInteger(value) || value < 0) {
265
+ throw new TypeError(`${name} must be a non-negative integer`);
266
+ }
267
+ return value;
268
+ }
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+
1
3
  import { sendJson } from './response.js';
2
4
 
3
5
  export function localServerAuthority(address) {
@@ -9,18 +11,38 @@ export function localServerAuthority(address) {
9
11
  }
10
12
 
11
13
  export function rejectDisallowedRequest({ request, response, authority }) {
14
+ return rejectRequestOutsidePolicy({ request, response, authority });
15
+ }
16
+
17
+ export function rejectRequestOutsidePolicy({
18
+ request,
19
+ response,
20
+ authority,
21
+ lanAuthorities = [],
22
+ lanAccessToken = null
23
+ }) {
12
24
  const origin = request.headers.origin;
13
25
  const fetchSite = request.headers['sec-fetch-site'];
26
+ const requestAuthority = request.headers.host;
27
+ const lanRequest = lanAuthorities.includes(requestAuthority);
28
+ const loopbackRequest = requestAuthority === authority;
14
29
  if (
15
30
  !authority ||
16
- request.headers.host !== authority ||
17
- (origin !== undefined && origin !== `http://${authority}`) ||
31
+ (requestAuthority !== authority && !lanRequest) ||
32
+ (lanAuthorities.length > 0 && loopbackRequest && !isLoopbackAddress(request.socket?.remoteAddress)) ||
33
+ (origin !== undefined && origin !== `http://${requestAuthority}`) ||
18
34
  (typeof fetchSite === 'string' && fetchSite.toLowerCase() === 'cross-site')
19
35
  ) {
20
36
  sendJson(response, 403, { error: 'Forbidden' });
21
37
  return true;
22
38
  }
23
39
 
40
+ if (lanRequest && !hasLanAccess(request.headers.authorization, lanAccessToken)) {
41
+ response.setHeader('www-authenticate', 'Basic realm="FULI LAN", charset="UTF-8"');
42
+ sendJson(response, 401, { error: 'LAN access code required' });
43
+ return true;
44
+ }
45
+
24
46
  if (requiresJson(request) && !isJson(request.headers['content-type'])) {
25
47
  sendJson(response, 415, { error: 'Unsupported media type' });
26
48
  return true;
@@ -29,8 +51,21 @@ export function rejectDisallowedRequest({ request, response, authority }) {
29
51
  return false;
30
52
  }
31
53
 
54
+ function hasLanAccess(authorization, accessToken) {
55
+ if (typeof accessToken !== 'string' || !accessToken) return false;
56
+ const expected = `Basic ${Buffer.from(`fuli:${accessToken}`).toString('base64')}`;
57
+ if (typeof authorization !== 'string' || authorization.length !== expected.length) {
58
+ return false;
59
+ }
60
+ return timingSafeEqual(Buffer.from(authorization), Buffer.from(expected));
61
+ }
62
+
63
+ function isLoopbackAddress(address) {
64
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
65
+ }
66
+
32
67
  function requiresJson(request) {
33
- if (request.method !== 'POST') return false;
68
+ if (!['PATCH', 'POST', 'PUT'].includes(request.method)) return false;
34
69
  const pathname = new URL(request.url, 'http://127.0.0.1').pathname;
35
70
  return pathname.startsWith('/api/');
36
71
  }