fuli-context 0.7.4-beta.2 → 0.7.4-beta.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 (43) hide show
  1. package/README.md +16 -1
  2. package/README.zh-CN.md +15 -1
  3. package/dist/web/assets/{AboutPage-CftRexSY.js → AboutPage-Dpf5fRWv.js} +1 -1
  4. package/dist/web/assets/ConnectionsPage-D21aUDCP.js +1 -0
  5. package/dist/web/assets/{KnowledgeEditDialog-wotVahvr.js → KnowledgeEditDialog-u7lpfN3j.js} +1 -1
  6. package/dist/web/assets/{KnowledgeInspector-BzkW0mVs.js → KnowledgeInspector-k4xnHtXd.js} +1 -1
  7. package/dist/web/assets/{KnowledgeOrganizerPage-BebUDiHt.js → KnowledgeOrganizerPage-Bdj7Rn84.js} +1 -1
  8. package/dist/web/assets/KnowledgePage-UHVAbC1W.js +1 -0
  9. package/dist/web/assets/{KnowledgeWorkspace-1pHJ0MNZ.js → KnowledgeWorkspace-Lq3d-MeC.js} +1 -1
  10. package/dist/web/assets/{OverviewPage-CHE6XhdS.js → OverviewPage-CycAgA1V.js} +1 -1
  11. package/dist/web/assets/{PersonalProfilePage-CXXiabUy.js → PersonalProfilePage-U9oIV5FT.js} +1 -1
  12. package/dist/web/assets/PersonalProjectsPage-C5RpXKcA.js +1 -0
  13. package/dist/web/assets/{PublicProjectsPage-hxLShtnZ.js → PublicProjectsPage-DiWxm35E.js} +1 -1
  14. package/dist/web/assets/{ReviewPage-DUeyu7E8.js → ReviewPage-jxRfGSJK.js} +1 -1
  15. package/dist/web/assets/{SearchableSelect-C5RiB5F8.js → SearchableSelect-B3-5IPup.js} +1 -1
  16. package/dist/web/assets/{SettingsPage-BMuASSzQ.js → SettingsPage-BqfGDOho.js} +1 -1
  17. package/dist/web/assets/{WritingTastePage-B9BCYIwQ.js → WritingTastePage-BJxhMqJR.js} +1 -1
  18. package/dist/web/assets/{console-DMvHxKf9.js → console-D_2zC4lm.js} +1 -1
  19. package/dist/web/assets/{conversation-launcher-settings-Dnlcw7Rj.js → conversation-launcher-settings-DQmMH2j7.js} +1 -1
  20. package/dist/web/assets/{i18n-CWfd8Dtt.js → i18n-CwqXDI1C.js} +1 -1
  21. package/dist/web/assets/{identity-C9mLdR8L.js → identity-DmGcY3Y6.js} +1 -1
  22. package/dist/web/assets/index-IUGLt4lG.js +2 -0
  23. package/dist/web/assets/{model-CsYAR8O3.js → model-CVoOdRsL.js} +1 -1
  24. package/dist/web/assets/{useApi-CROJJdhE-BLQiPVw0.js → useApi-CROJJdhE-CBNg4CdH.js} +1 -1
  25. package/dist/web/assets/{vue-router-DN8x6iY1.js → vue-router-CWSsUUio.js} +1 -1
  26. package/dist/web/index.html +5 -5
  27. package/docs/public-personal-architecture.md +73 -0
  28. package/graph-provider/fuli_graph/model_validation.py +4 -0
  29. package/graph-provider/pyproject.toml +1 -1
  30. package/npm-shrinkwrap.json +2 -2
  31. package/package.json +2 -1
  32. package/skills/capturing-session-knowledge/SKILL.md +4 -2
  33. package/src/cli/command-registry.js +1 -0
  34. package/src/cli/workspace-connection-command.js +333 -0
  35. package/src/cli.js +5 -0
  36. package/src/graphiti/federated-application.js +21 -22
  37. package/src/graphiti/knowledge-provider-mapping.js +7 -1
  38. package/src/graphiti/runtime-config.js +6 -1
  39. package/src/graphiti/workspace-provider-client.js +403 -0
  40. package/dist/web/assets/ConnectionsPage-D5J91x6d.js +0 -1
  41. package/dist/web/assets/KnowledgePage-OUT07aKH.js +0 -1
  42. package/dist/web/assets/PersonalProjectsPage-d2wYGW8n.js +0 -1
  43. package/dist/web/assets/index-nP5p5OaY.js +0 -2
@@ -0,0 +1,333 @@
1
+ import {
2
+ chmodSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ writeFileSync
6
+ } from 'node:fs';
7
+
8
+ import {
9
+ canonicalProviderUrl,
10
+ GraphRuntimeConfigurationError
11
+ } from '../graphiti/runtime-config.js';
12
+ import { resolveSetupPaths } from '../setup/paths.js';
13
+ import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
14
+
15
+ const HELP_FLAGS = new Set(['--help', '-h']);
16
+ const VALUE_FLAGS = new Set(['--url', '--token-file', '--data-dir']);
17
+ const WORKSPACE_ROLES = new Set(['reader', 'contributor', 'maintainer', 'admin']);
18
+ const MAX_HANDSHAKE_BYTES = 64 * 1024;
19
+ const MAX_TOKEN_BYTES = 4096;
20
+
21
+ export class WorkspaceConnectionError extends Error {}
22
+
23
+ export async function runWorkspaceConnectionCommand(args, dependencies = {}) {
24
+ const options = parseWorkspaceConnectionOptions(args);
25
+ const write = dependencies.write ?? writeLine;
26
+ if (options.help) {
27
+ write(workspaceConnectionHelp());
28
+ return { status: 'help' };
29
+ }
30
+
31
+ const providerUrl = normalizeProviderUrl(options.url);
32
+ const token = readSecureToken(options.tokenFile, dependencies);
33
+ const fetchImpl = dependencies.fetch ?? globalThis.fetch;
34
+ if (typeof fetchImpl !== 'function') {
35
+ throw new WorkspaceConnectionError('This Node.js runtime does not provide fetch.');
36
+ }
37
+
38
+ const discovery = await requestJson({
39
+ fetchImpl,
40
+ url: new URL('/.well-known/fuli-workspace', `${providerUrl}/`),
41
+ expectedOrigin: new URL(providerUrl).origin,
42
+ timeoutMs: dependencies.timeoutMs
43
+ });
44
+ const provider = validateDiscovery(discovery, providerUrl);
45
+ const session = await requestJson({
46
+ fetchImpl,
47
+ url: provider.authSessionUrl,
48
+ expectedOrigin: new URL(providerUrl).origin,
49
+ token,
50
+ timeoutMs: dependencies.timeoutMs
51
+ });
52
+ const identity = validateSession(session);
53
+
54
+ const resolvePaths = dependencies.resolvePaths ?? resolveSetupPaths;
55
+ const paths = resolvePaths({ dataDir: options.dataDir, env: dependencies.env ?? process.env });
56
+ const runtimeConfigPath = paths.graphRuntimeConfigPath;
57
+ const readConfig = dependencies.readConfig ?? readJsonFile;
58
+ let runtimeConfig;
59
+ try {
60
+ runtimeConfig = readConfig(runtimeConfigPath, null);
61
+ } catch {
62
+ throw new WorkspaceConnectionError('Could not read the Fuli runtime configuration.');
63
+ }
64
+ if (runtimeConfig?.version !== 1 || !runtimeConfig.personal
65
+ || !Array.isArray(runtimeConfig.workspaces)) {
66
+ throw new WorkspaceConnectionError('Fuli is not initialized. Run `fuli setup` first.');
67
+ }
68
+
69
+ const workspace = {
70
+ protocol: 'fuli-workspace-v1',
71
+ providerUrl,
72
+ providerId: provider.id,
73
+ accessToken: token,
74
+ principalId: identity.principalId,
75
+ role: identity.role,
76
+ workspaceIds: identity.workspaceIds
77
+ };
78
+ const nextConfig = {
79
+ ...runtimeConfig,
80
+ workspaces: upsertWorkspace(runtimeConfig.workspaces, workspace)
81
+ };
82
+ const writeConfig = dependencies.writeConfig ?? writeSecureJson;
83
+ const secureFile = dependencies.secureFile ?? ((path) => chmodSync(path, 0o600));
84
+ try {
85
+ writeConfig(runtimeConfigPath, nextConfig);
86
+ secureFile(runtimeConfigPath);
87
+ } catch {
88
+ throw new WorkspaceConnectionError('Could not update the Fuli runtime configuration.');
89
+ }
90
+
91
+ write([
92
+ `Shared workspace service configured: ${providerUrl}`,
93
+ 'Restart required: run `fuli restart` to apply this connection.'
94
+ ].join('\n'));
95
+ return { status: 'configured', providerUrl, restartRequired: true };
96
+ }
97
+
98
+ export function parseWorkspaceConnectionOptions(args) {
99
+ if (!Array.isArray(args)) throw new WorkspaceConnectionError('Command arguments are invalid.');
100
+ if (args.some((argument) => HELP_FLAGS.has(argument))) {
101
+ if (args.length !== 1) {
102
+ throw new WorkspaceConnectionError('The help flag cannot be combined with other options.');
103
+ }
104
+ return { help: true, url: null, tokenFile: null, dataDir: null };
105
+ }
106
+
107
+ const values = new Map();
108
+ for (let index = 0; index < args.length; index += 2) {
109
+ const flag = args[index];
110
+ if (!VALUE_FLAGS.has(flag)) {
111
+ throw new WorkspaceConnectionError('Unknown option for connect-workspace.');
112
+ }
113
+ if (values.has(flag)) {
114
+ throw new WorkspaceConnectionError(`Duplicate connect-workspace option: ${flag}`);
115
+ }
116
+ const value = args[index + 1];
117
+ if (typeof value !== 'string' || !value.trim() || value.startsWith('--')) {
118
+ throw new WorkspaceConnectionError(`Missing value for ${flag}`);
119
+ }
120
+ values.set(flag, value.trim());
121
+ }
122
+
123
+ if (!values.has('--url')) {
124
+ throw new WorkspaceConnectionError('Missing required option: --url');
125
+ }
126
+ if (!values.has('--token-file')) {
127
+ throw new WorkspaceConnectionError('Missing required option: --token-file');
128
+ }
129
+ return {
130
+ help: false,
131
+ url: values.get('--url'),
132
+ tokenFile: values.get('--token-file'),
133
+ dataDir: values.get('--data-dir') ?? null
134
+ };
135
+ }
136
+
137
+ export function workspaceConnectionHelp() {
138
+ return [
139
+ 'Usage: fuli connect-workspace --url URL --token-file FILE [--data-dir DIR]',
140
+ '',
141
+ 'Validates a Fuli Workspace service and stores its bearer token in the local',
142
+ 'Fuli runtime configuration. Restart Fuli after the command succeeds.'
143
+ ].join('\n');
144
+ }
145
+
146
+ function normalizeProviderUrl(value) {
147
+ try {
148
+ return new URL(canonicalProviderUrl(value)).origin;
149
+ } catch (error) {
150
+ if (error instanceof GraphRuntimeConfigurationError) {
151
+ throw new WorkspaceConnectionError(error.message);
152
+ }
153
+ throw new WorkspaceConnectionError('Workspace service URL is invalid.');
154
+ }
155
+ }
156
+
157
+ function readSecureToken(path, dependencies) {
158
+ const inspect = dependencies.lstat ?? lstatSync;
159
+ const readText = dependencies.readText ?? ((filePath) => readFileSync(filePath, 'utf8'));
160
+ let metadata;
161
+ try {
162
+ metadata = inspect(path);
163
+ } catch {
164
+ throw new WorkspaceConnectionError('The token file could not be inspected.');
165
+ }
166
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
167
+ throw new WorkspaceConnectionError('The token file must be a regular file, not a symlink.');
168
+ }
169
+ const platform = dependencies.platform ?? process.platform;
170
+ if (platform !== 'win32' && (metadata.mode & 0o077) !== 0) {
171
+ throw new WorkspaceConnectionError('The token file must not be readable by group or other users.');
172
+ }
173
+ if (!Number.isFinite(metadata.size) || metadata.size < 1 || metadata.size > MAX_TOKEN_BYTES) {
174
+ throw new WorkspaceConnectionError('The token file has an invalid size.');
175
+ }
176
+
177
+ let token;
178
+ try {
179
+ token = readText(path).trim();
180
+ } catch {
181
+ throw new WorkspaceConnectionError('The token file could not be read.');
182
+ }
183
+ if (token.length < 20 || token.length > MAX_TOKEN_BYTES || !/^[\x21-\x7e]+$/u.test(token)) {
184
+ throw new WorkspaceConnectionError('The token file does not contain a valid bearer token.');
185
+ }
186
+ return token;
187
+ }
188
+
189
+ async function requestJson({ fetchImpl, url, expectedOrigin, token = null, timeoutMs = 5000 }) {
190
+ let response;
191
+ try {
192
+ response = await fetchImpl(url, {
193
+ method: 'GET',
194
+ headers: {
195
+ accept: 'application/json',
196
+ ...(token ? { authorization: `Bearer ${token}` } : {})
197
+ },
198
+ redirect: 'manual',
199
+ cache: 'no-store',
200
+ signal: AbortSignal.timeout(timeoutMs)
201
+ });
202
+ } catch {
203
+ throw new WorkspaceConnectionError('Could not reach the workspace service.');
204
+ }
205
+ if (response.status >= 300 && response.status < 400) {
206
+ throw new WorkspaceConnectionError('Workspace service redirects are not allowed during connection.');
207
+ }
208
+ if (response.url) {
209
+ let responseOrigin;
210
+ try {
211
+ responseOrigin = new URL(response.url).origin;
212
+ } catch {
213
+ throw new WorkspaceConnectionError('Workspace service returned an invalid response URL.');
214
+ }
215
+ if (responseOrigin !== expectedOrigin) {
216
+ throw new WorkspaceConnectionError('Workspace service returned a cross-origin response.');
217
+ }
218
+ }
219
+ if (!response.ok) {
220
+ throw new WorkspaceConnectionError(`Workspace service rejected the connection (HTTP ${response.status}).`);
221
+ }
222
+
223
+ let text;
224
+ try {
225
+ text = await response.text();
226
+ } catch {
227
+ throw new WorkspaceConnectionError('Workspace service returned an unreadable response.');
228
+ }
229
+ if (Buffer.byteLength(text, 'utf8') > MAX_HANDSHAKE_BYTES) {
230
+ throw new WorkspaceConnectionError('Workspace service response is too large.');
231
+ }
232
+ try {
233
+ return JSON.parse(text);
234
+ } catch {
235
+ throw new WorkspaceConnectionError('Workspace service did not return valid JSON.');
236
+ }
237
+ }
238
+
239
+ function validateDiscovery(discovery, providerUrl) {
240
+ if (String(discovery?.protocolVersion ?? '') !== '1') {
241
+ throw new WorkspaceConnectionError('Workspace service protocol version is not supported.');
242
+ }
243
+ const id = safeIdentifier(discovery?.provider?.id, 'Workspace service provider ID is invalid.');
244
+ const advertised = discovery?.endpoints?.authSession ?? '/v1/auth/session';
245
+ const authSessionUrl = sameOriginEndpoint(advertised, providerUrl);
246
+ return { id, authSessionUrl };
247
+ }
248
+
249
+ function validateSession(session) {
250
+ if (session?.authenticated !== true) {
251
+ throw new WorkspaceConnectionError('The workspace service did not authenticate this token.');
252
+ }
253
+ const principalId = safeIdentifier(
254
+ session?.principal?.id,
255
+ 'Workspace service returned an invalid principal.'
256
+ );
257
+ const grant = session?.grant && typeof session.grant === 'object' ? session.grant : session;
258
+ const role = String(grant?.role ?? '');
259
+ if (!WORKSPACE_ROLES.has(role)) {
260
+ throw new WorkspaceConnectionError('Workspace service returned an unsupported role.');
261
+ }
262
+ const workspaceIds = normalizeWorkspaceIds(grant?.workspaceIds);
263
+ return { principalId, role, workspaceIds };
264
+ }
265
+
266
+ function normalizeWorkspaceIds(value) {
267
+ if (value === '*') return '*';
268
+ if (!Array.isArray(value)) {
269
+ throw new WorkspaceConnectionError('Workspace service returned an invalid workspace grant.');
270
+ }
271
+ return value.map((id) => safeIdentifier(id, 'Workspace service returned an invalid workspace grant.'));
272
+ }
273
+
274
+ function safeIdentifier(value, message) {
275
+ if (typeof value !== 'string') throw new WorkspaceConnectionError(message);
276
+ const normalized = value.trim();
277
+ if (!normalized || normalized.length > 256 || /[\u0000-\u001f\u007f]/u.test(normalized)) {
278
+ throw new WorkspaceConnectionError(message);
279
+ }
280
+ return normalized;
281
+ }
282
+
283
+ function sameOriginEndpoint(value, providerUrl) {
284
+ let endpoint;
285
+ try {
286
+ endpoint = new URL(String(value), `${providerUrl}/`);
287
+ } catch {
288
+ throw new WorkspaceConnectionError('Workspace service advertised an invalid authentication endpoint.');
289
+ }
290
+ const providerOrigin = new URL(providerUrl).origin;
291
+ if (endpoint.origin !== providerOrigin || endpoint.username || endpoint.password
292
+ || endpoint.search || endpoint.hash) {
293
+ throw new WorkspaceConnectionError('Workspace service advertised a cross-origin authentication endpoint.');
294
+ }
295
+ return endpoint;
296
+ }
297
+
298
+ function upsertWorkspace(workspaces, workspace) {
299
+ const next = [];
300
+ let replaced = false;
301
+ for (const existing of workspaces) {
302
+ if (!sameProvider(existing?.providerUrl, workspace.providerUrl)) {
303
+ next.push(existing);
304
+ continue;
305
+ }
306
+ if (!replaced) next.push({ ...existing, ...workspace });
307
+ replaced = true;
308
+ }
309
+ if (!replaced) next.push(workspace);
310
+ return next;
311
+ }
312
+
313
+ function sameProvider(left, right) {
314
+ try {
315
+ return new URL(canonicalProviderUrl(left)).origin === right;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
321
+ function writeSecureJson(path, value) {
322
+ writeJsonFileAtomic(path, value, {
323
+ writeFileSync: (temporaryPath, payload, options) => writeFileSync(
324
+ temporaryPath,
325
+ payload,
326
+ { ...options, mode: 0o600 }
327
+ )
328
+ });
329
+ }
330
+
331
+ function writeLine(value) {
332
+ process.stdout.write(`${value}\n`);
333
+ }
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { runLocalRuntimeCommand } from './cli/local-runtime-command.js';
10
10
  import { runSetupCommand } from './cli/setup-command.js';
11
11
  import { runUninstallCommand } from './cli/uninstall-command.js';
12
12
  import { runUpdateCommand } from './cli/update-command.js';
13
+ import { runWorkspaceConnectionCommand } from './cli/workspace-connection-command.js';
13
14
  import { FULI_VERSION } from './package-metadata.js';
14
15
  import { assertSupportedNodeVersion } from './setup/node-runtime.js';
15
16
 
@@ -28,6 +29,10 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
28
29
  await runSetupCommand(commandArgs, { env });
29
30
  return;
30
31
  }
32
+ if (command === 'connect-workspace') {
33
+ assertSupportedNodeVersion();
34
+ return runWorkspaceConnectionCommand(commandArgs, { env });
35
+ }
31
36
  if (command === 'uninstall') {
32
37
  await runUninstallCommand(commandArgs, { env });
33
38
  return;
@@ -1,6 +1,10 @@
1
1
  import { dirname } from 'node:path';
2
2
 
3
3
  import { GraphitiProviderClient } from './provider-client.js';
4
+ import {
5
+ aggregatePublicCapabilities,
6
+ createWorkspaceProvider
7
+ } from './workspace-provider-client.js';
4
8
  import {
5
9
  canonicalProviderUrl,
6
10
  readGraphRuntimeConfig
@@ -153,20 +157,12 @@ export class FederatedGraphApplication {
153
157
  fetchImpl,
154
158
  requestTimeoutMs: providerRequestTimeoutMs
155
159
  });
156
- this.workspaces = new Map(
157
- config.workspaces.map((workspace) => {
158
- const url = canonicalProviderUrl(workspace.providerUrl);
159
- return [url, {
160
- ...workspace,
161
- client: new GraphitiProviderClient({
162
- baseUrl: url,
163
- accessToken: workspace.accessToken,
164
- fetchImpl,
165
- requestTimeoutMs: providerRequestTimeoutMs
166
- })
167
- }];
168
- })
169
- );
160
+ this.workspaces = new Map(config.workspaces.map((workspace) => {
161
+ const provider = createWorkspaceProvider(workspace, {
162
+ fetchImpl, requestTimeoutMs: providerRequestTimeoutMs
163
+ });
164
+ return [provider.providerUrl, provider];
165
+ }));
170
166
  }
171
167
 
172
168
  async beginTaskContext(input) {
@@ -1159,10 +1155,17 @@ export class FederatedGraphApplication {
1159
1155
  try {
1160
1156
  return {
1161
1157
  providerUrl: workspace.providerUrl,
1158
+ protocol: workspace.protocol,
1159
+ capabilities: workspace.capabilities,
1162
1160
  ...(await workspace.client.health())
1163
1161
  };
1164
1162
  } catch {
1165
- return { providerUrl: workspace.providerUrl, status: 'unavailable' };
1163
+ return {
1164
+ providerUrl: workspace.providerUrl,
1165
+ protocol: workspace.protocol,
1166
+ capabilities: workspace.capabilities,
1167
+ status: 'unavailable'
1168
+ };
1166
1169
  }
1167
1170
  })
1168
1171
  ]);
@@ -1200,13 +1203,9 @@ export class FederatedGraphApplication {
1200
1203
  capturePolicy: this.getCapturePolicy(),
1201
1204
  agentAccessPolicy: this.getAgentAccessPolicy(),
1202
1205
  publicProvider: { configured, status: publicStatus },
1203
- capabilities: {
1204
- browsePublicProjects: connected,
1205
- publishProject: connected,
1206
- submitKnowledge: connected,
1207
- subscribeProject: connected,
1208
- reviewProposals: connected && spaces.projects.some(({ role }) => role === 'maintainer')
1209
- }
1206
+ capabilities: aggregatePublicCapabilities(
1207
+ this.workspaces, providers.workspaces, spaces.projects
1208
+ )
1210
1209
  };
1211
1210
  }
1212
1211
 
@@ -71,7 +71,8 @@ function assertReasoningSummaries(items, collectionName) {
71
71
  }
72
72
 
73
73
  export function assertPublicKnowledgeEligible(episode) {
74
- const blocked = [...episode.entities, ...episode.relationships].find((item) =>
74
+ const items = [...episode.entities, ...episode.relationships];
75
+ const blocked = items.find((item) =>
75
76
  item.profile_aspect ||
76
77
  item.confirmation_status !== 'confirmed' ||
77
78
  !item.confirmation_basis?.confirmed_by ||
@@ -83,6 +84,11 @@ export function assertPublicKnowledgeEligible(episode) {
83
84
  'Only knowledge with an auditable confirmation can enter public review'
84
85
  );
85
86
  }
87
+ if (items.some((item) => (item.origin_quadrant ?? 'known_known') !== 'known_known')) {
88
+ throw new TypeError(
89
+ 'Only knowledge originally captured as known-known can enter public review'
90
+ );
91
+ }
86
92
  }
87
93
 
88
94
  export function providerConfirmationBasis(basis) {
@@ -38,7 +38,12 @@ function validateConfig(config) {
38
38
  if (!config.personal.spaceId) fail('personal.spaceId is required');
39
39
  if (!Array.isArray(config.workspaces)) fail('workspaces must be an array');
40
40
  for (const [index, workspace] of config.workspaces.entries()) {
41
- provider(workspace, `workspaces[${index}]`);
41
+ const label = `workspaces[${index}]`;
42
+ provider(workspace, label);
43
+ if (workspace.protocol !== undefined &&
44
+ !['graphiti-v1', 'fuli-workspace-v1'].includes(workspace.protocol)) {
45
+ fail(`${label}.protocol is unsupported`);
46
+ }
42
47
  }
43
48
  }
44
49