fuli-context 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.en.md +336 -0
  2. package/README.md +220 -174
  3. package/dist/web/assets/ConnectionsPage-CCKWm-6p.js +1 -0
  4. package/dist/web/assets/KnowledgeInspector-BZkB2wJn.js +1 -0
  5. package/dist/web/assets/KnowledgeInspector-CrGYxEob.css +1 -0
  6. package/dist/web/assets/KnowledgeOrganizerPage-CZAF_OlN.css +1 -0
  7. package/dist/web/assets/KnowledgeOrganizerPage-DlvcLwlL.js +1 -0
  8. package/dist/web/assets/KnowledgePage-CNQXd15y.js +1 -0
  9. package/dist/web/assets/KnowledgeWorkspace-CXPE4GCD.js +4 -0
  10. package/dist/web/assets/OverviewPage-ul7g7HXh.js +1 -0
  11. package/dist/web/assets/PersonalProfilePage-CC40Pw2L.js +1 -0
  12. package/dist/web/assets/PersonalProfilePage-S8ZcG2LU.css +1 -0
  13. package/dist/web/assets/PersonalProjectsPage-CPIEcShD.js +1 -0
  14. package/dist/web/assets/PublicProjectsPage-C700sR7z.js +1 -0
  15. package/dist/web/assets/ReviewPage-7MDDUDgA.js +1 -0
  16. package/dist/web/assets/console-B_Ena2DS.js +5 -0
  17. package/dist/web/assets/identity-CjBwYSMX.js +1 -0
  18. package/dist/web/assets/{index-Devf7glZ.css → index-CpLDx3of.css} +1 -1
  19. package/dist/web/assets/index-DAzeFJSc.js +2 -0
  20. package/dist/web/assets/model-C-5RVIJ2.js +1 -0
  21. package/dist/web/assets/{paths-B790iniO.js → paths-BkULqmGI.js} +1 -1
  22. package/dist/web/assets/{vue-router-BYTAoWT3.js → vue-router-DnD3hUm7.js} +1 -1
  23. package/dist/web/index.html +5 -6
  24. package/graph-provider/fuli_graph/app.py +48 -0
  25. package/graph-provider/fuli_graph/common_knowledge.py +443 -0
  26. package/graph-provider/fuli_graph/common_knowledge_models.py +54 -0
  27. package/graph-provider/fuli_graph/graph_models.py +9 -0
  28. package/graph-provider/fuli_graph/graph_query.py +25 -0
  29. package/graph-provider/fuli_graph/knowledge_audit.py +211 -0
  30. package/graph-provider/fuli_graph/knowledge_feedback_models.py +69 -0
  31. package/graph-provider/fuli_graph/knowledge_management.py +37 -0
  32. package/graph-provider/fuli_graph/knowledge_search.py +24 -2
  33. package/graph-provider/fuli_graph/models.py +69 -0
  34. package/graph-provider/fuli_graph/search_projection.py +59 -0
  35. package/graph-provider/fuli_graph/store_knowledge.py +2 -0
  36. package/graph-provider/pyproject.toml +1 -1
  37. package/npm-shrinkwrap.json +97 -2
  38. package/package.json +3 -1
  39. package/skills/capturing-session-knowledge/SKILL.md +83 -13
  40. package/src/agent-tools/graph-definitions.js +181 -13
  41. package/src/agent-tools/graph-handlers.js +19 -0
  42. package/src/cli/command-registry.js +3 -58
  43. package/src/cli/local-runtime-command.js +16 -2
  44. package/src/cli/local-runtime-options.js +6 -2
  45. package/src/cli.js +2 -37
  46. package/src/graphiti/agent-knowledge-workflows.js +543 -0
  47. package/src/graphiti/federated-application.js +85 -104
  48. package/src/graphiti/knowledge-provider-mapping.js +91 -0
  49. package/src/graphiti/provider-client.js +15 -0
  50. package/src/graphiti/source-uri.js +29 -0
  51. package/src/http/request-policy.js +37 -2
  52. package/src/local-runtime/lifecycle.js +11 -1
  53. package/src/mcp/common-knowledge-preview-tokens.js +67 -0
  54. package/src/mcp/create-mcp-server.js +43 -7
  55. package/src/mcp/instructions.js +6 -7
  56. package/src/mcp/lifecycle-audit.js +27 -0
  57. package/src/mcp/task-context-registry.js +93 -0
  58. package/src/mcp/tool-annotations.js +16 -1
  59. package/src/mcp/tool-result.js +24 -0
  60. package/src/runtime-options.js +7 -8
  61. package/src/server/lan-access.js +52 -0
  62. package/src/server.js +67 -8
  63. package/src/setup/agent-installation-status.js +14 -2
  64. package/src/setup/agents.js +12 -1
  65. package/src/setup/claude-code-config.js +194 -0
  66. package/src/setup/config-backup.js +20 -5
  67. package/src/setup/graph-runtime.js +78 -9
  68. package/dist/web/assets/ConnectionsPage-CY6H_mJJ.js +0 -1
  69. package/dist/web/assets/KnowledgeInspector-BSpz5Sfm.js +0 -1
  70. package/dist/web/assets/KnowledgeInspector-CHbB_0rC.css +0 -1
  71. package/dist/web/assets/KnowledgeOrganizerPage-BlEvhPrv.css +0 -1
  72. package/dist/web/assets/KnowledgeOrganizerPage-CvjGPlUc.js +0 -1
  73. package/dist/web/assets/KnowledgePage-Khuo3cNs.js +0 -1
  74. package/dist/web/assets/KnowledgeWorkspace-DVZusvem.js +0 -4
  75. package/dist/web/assets/OverviewPage-DC4C4Fhn.js +0 -1
  76. package/dist/web/assets/PersonalProfilePage-CSV9Mtgz.js +0 -1
  77. package/dist/web/assets/PersonalProfilePage-CVJ3TcRi.css +0 -1
  78. package/dist/web/assets/PersonalProjectsPage-A50EaseQ.js +0 -1
  79. package/dist/web/assets/PublicProjectsPage-BAdO_bae.js +0 -1
  80. package/dist/web/assets/ReviewPage-Dztk2jDG.js +0 -1
  81. package/dist/web/assets/console-tbvTUi6p.js +0 -1
  82. package/dist/web/assets/identity-B3s5CXhv.js +0 -1
  83. package/dist/web/assets/index-DwUDhQKc.js +0 -6
  84. package/dist/web/assets/model-DVT8sjy9.js +0 -1
  85. package/dist/web/assets/runtime-dom.esm-bundler-DttsK21F.js +0 -1
  86. package/src/cli/commands/candidates.js +0 -17
  87. package/src/cli/commands/ingestion.js +0 -32
  88. package/src/cli/commands/reads.js +0 -50
  89. package/src/cli/commands/spaces.js +0 -20
  90. package/src/cli/invocation.js +0 -47
  91. package/src/cli/migrate-command.js +0 -71
  92. package/src/cli/migration-destination.js +0 -117
  93. package/src/storage/import-json.js +0 -70
  94. package/src/storage/legacy-record-validation.js +0 -152
  95. package/src/storage/legacy-snapshot-validation.js +0 -32
@@ -1,10 +1,9 @@
1
1
  export const MCP_INSTRUCTIONS = [
2
- "Each user task: call exactly get_collaboration_preferences before other tools/answer with projectPath=cwd; never substitute a project action or Fuli tool. Apply all effective_preferences: personal-global everywhere plus only Fuli's matched project. Resolve relevant deferred_conflict via resolve_deferred_preference_conflict; ignore others. Pending is search-only. Never guess personalProjectId. Writes must enforce preferences in the actual payload; final text is not compliance.",
3
- 'Before claiming URLs, routes, requirements, prior decisions, or runbooks are unknown, call search_knowledge_graph; resolve IDs via list_knowledge_spaces/list_personal_projects.',
4
- 'Search personal-global profile, exact personalProjectId, inheritable knowledge reached via PART_OF or USES_KNOWLEDGE_FROM, named contextPersonalProjectIds, and relevant subscribed public project IDs. Never inherit project preferences or traverse RELATED_TO. Pending results stay marked. On a location miss, ask to widen to all registered local personal projects; use all_local_confirmed only after explicit confirmation. It never expands public projects.',
5
- 'After a confirmed all-local miss, search current repository or workspace files with Grep or rg. Never scan user home, filesystem root, outside the workspace, credentials, browser data, or unrelated repositories. Require deployment evidence for live URLs.',
6
- 'Changing/live: use monitoring or Git MCP',
7
- 'Silently batch via capture_session_knowledge under personalProjectId. Never store secrets, transcripts, or guesses. Only human/source confirmed knowledge enters public review. Final checkpoint: flush or retain nothing.',
8
- 'When personal knowledge affects the final answer/action, call record_knowledge_usage. Retrieval/injection never count; it creates at most agent_confirmed, never public eligibility.',
2
+ "Each user task: if begin_task_context supplied hook-provided task context, apply. Otherwise call exactly get_collaboration_preferences with projectPath=cwd before other tools/answer as the fallback; never substitute a project action or Fuli tool. Apply all effective_preferences: personal-global everywhere plus only Fuli's matched project. Resolve relevant deferred_conflict first. Never guess personalProjectId. Writes enforce preferences in the actual payload; final text is not compliance.",
3
+ 'For current-project facts use search_current_project_knowledge: exact active child first, then inheritable parent knowledge via outgoing PART_OF/USES_KNOWLEDGE_FROM within two hops. Child stable keys override. Never inherit project preferences or traverse RELATED_TO.',
4
+ 'Before claiming facts unknown, use search_knowledge_graph. Use exact IDs for extra projects; mark pending. Widen a location miss to all_local_confirmed only after consent, then use rg only in the safe current repo/workspace. Never scan outside it. Live URLs need deployment evidence; changing state needs monitoring/Git MCP.',
5
+ 'Use record_decision_trace for confirmed choices/rationale/validation. Use record_knowledge_feedback when retained knowledge is rejected, contradicted, outdated, or fails validation; preserve history.',
6
+ 'Batch durable confirmed knowledge via capture_session_knowledge in exact scope. Never store secrets, transcripts, or guesses. Only human/source confirmation permits public review. Finish a hook-managed task with checkpoint_task_knowledge once: capture_candidates or retain_nothing; fallback Agents likewise flush or retain nothing.',
7
+ 'When personal knowledge materially affects the answer/action, call record_knowledge_usage. Retrieval/injection never counts; it creates at most agent_confirmed and is never public-eligible.',
9
8
  'Terminal-safe Markdown. Final MUST begin with sourceMarker.leadMarkdown; append sourceMarker.markdown unchanged only on match. On miss use noMatchSourceMarker leadMarkdown only (empty markdown). Never wrap markers in HTML or cite writes.'
10
9
  ].join(' ');
@@ -0,0 +1,27 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ const AUDITED_TOOLS = new Set([
4
+ 'begin_task_context',
5
+ 'checkpoint_task_knowledge',
6
+ 'verify_task_checkpoint'
7
+ ]);
8
+
9
+ export function auditLifecycleTool(
10
+ toolName,
11
+ {
12
+ auditPath = process.env.FULI_ACCEPTANCE_LIFECYCLE_AUDIT_PATH,
13
+ append = appendFileSync
14
+ } = {}
15
+ ) {
16
+ if (!auditPath || !AUDITED_TOOLS.has(toolName)) return false;
17
+ try {
18
+ append(
19
+ auditPath,
20
+ `${JSON.stringify({ event: toolName })}\n`,
21
+ { encoding: 'utf8', mode: 0o600 }
22
+ );
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const MAX_ACTIVE_SESSIONS = 256;
4
+
5
+ export class TaskContextRegistry {
6
+ constructor({ tokenFactory = () => `fuli-task-${randomUUID()}` } = {}) {
7
+ this.tokenFactory = tokenFactory;
8
+ this.sessions = new Map();
9
+ this.tokens = new Map();
10
+ }
11
+
12
+ begin({ sessionId, personalProjectId }) {
13
+ assertNonEmpty(sessionId, 'sessionId');
14
+ const previous = this.sessions.get(sessionId) ?? null;
15
+ if (previous) this.tokens.delete(previous.token);
16
+
17
+ const task = {
18
+ token: this.tokenFactory(),
19
+ sessionId,
20
+ personalProjectId: personalProjectId ?? null,
21
+ checkpoint: null
22
+ };
23
+ this.sessions.delete(sessionId);
24
+ this.sessions.set(sessionId, task);
25
+ this.tokens.set(task.token, task);
26
+ this.#evictOldest();
27
+ return {
28
+ ...task,
29
+ previousCheckpointMissing: Boolean(previous && !previous.checkpoint)
30
+ };
31
+ }
32
+
33
+ checkpoint(token, checkpoint) {
34
+ assertNonEmpty(token, 'taskContextToken');
35
+ const task = this.tokens.get(token);
36
+ if (!task) throw new TypeError('Unknown or expired Fuli task context token');
37
+ if (task.checkpoint) {
38
+ if (sameCheckpoint(task.checkpoint, checkpoint)) return task;
39
+ throw new TypeError('Fuli task context has already been checkpointed');
40
+ }
41
+ task.checkpoint = Object.freeze({ ...checkpoint });
42
+ return task;
43
+ }
44
+
45
+ context(token) {
46
+ assertNonEmpty(token, 'taskContextToken');
47
+ const task = this.tokens.get(token);
48
+ if (!task) throw new TypeError('Unknown or expired Fuli task context token');
49
+ return task;
50
+ }
51
+
52
+ verify(sessionId) {
53
+ assertNonEmpty(sessionId, 'sessionId');
54
+ const task = this.sessions.get(sessionId);
55
+ if (!task) {
56
+ return {
57
+ status: 'not_started',
58
+ guidance: 'No Fuli task context was started; do not block the Agent.'
59
+ };
60
+ }
61
+ if (task.checkpoint) {
62
+ return {
63
+ status: 'checkpointed',
64
+ disposition: task.checkpoint.disposition
65
+ };
66
+ }
67
+ return {
68
+ status: 'checkpoint_required',
69
+ decision: 'block',
70
+ reason: 'Before finishing, call checkpoint_task_knowledge once with either a bounded durable candidate batch or retain_nothing. Do not store raw transcripts, guesses, or temporary output.'
71
+ };
72
+ }
73
+
74
+ #evictOldest() {
75
+ while (this.sessions.size > MAX_ACTIVE_SESSIONS) {
76
+ const [sessionId, task] = this.sessions.entries().next().value;
77
+ this.sessions.delete(sessionId);
78
+ this.tokens.delete(task.token);
79
+ }
80
+ }
81
+ }
82
+
83
+ function assertNonEmpty(value, label) {
84
+ if (typeof value !== 'string' || !value.trim()) {
85
+ throw new TypeError(`${label} must be a nonempty string`);
86
+ }
87
+ }
88
+
89
+ function sameCheckpoint(left, right) {
90
+ return left.disposition === right.disposition
91
+ && left.reason === right.reason
92
+ && left.captureStatus === right.captureStatus;
93
+ }
@@ -1,4 +1,8 @@
1
1
  const READ_TOOLS = new Set([
2
+ 'verify_task_checkpoint',
3
+ 'search_current_project_knowledge',
4
+ 'discover_common_knowledge_candidates',
5
+ 'preview_common_knowledge_promotion',
2
6
  'list_knowledge_spaces',
3
7
  'list_personal_projects',
4
8
  'preview_personal_project_action',
@@ -10,11 +14,16 @@ const READ_TOOLS = new Set([
10
14
  ]);
11
15
 
12
16
  const WRITE_TOOLS = new Set([
17
+ 'begin_task_context',
18
+ 'checkpoint_task_knowledge',
13
19
  'get_collaboration_preferences',
14
20
  'resolve_deferred_preference_conflict',
15
21
  'capture_session_knowledge',
22
+ 'record_decision_trace',
16
23
  'search_knowledge_graph',
17
24
  'record_knowledge_usage',
25
+ 'record_knowledge_feedback',
26
+ 'apply_common_knowledge_promotion',
18
27
  'get_knowledge_graph',
19
28
  'search_human_knowledge_changes',
20
29
  'review_human_knowledge_change',
@@ -34,6 +43,7 @@ const WRITE_TOOLS = new Set([
34
43
 
35
44
  const DESTRUCTIVE_TOOLS = new Set([
36
45
  'resolve_deferred_preference_conflict',
46
+ 'apply_common_knowledge_promotion',
37
47
  'review_project_proposal',
38
48
  'review_project_relation',
39
49
  'review_personal_draft',
@@ -50,8 +60,13 @@ export function annotationsFor(name) {
50
60
  destructiveHint: DESTRUCTIVE_TOOLS.has(name),
51
61
  idempotentHint: readOnly || name === 'upsert_personal_project' ||
52
62
  name === 'publish_personal_project' || name === 'unsubscribe_public_project' ||
53
- name === 'record_knowledge_usage',
63
+ name === 'record_knowledge_usage' || name === 'record_decision_trace' ||
64
+ name === 'record_knowledge_feedback' ||
65
+ name === 'checkpoint_task_knowledge',
54
66
  openWorldHint: name === 'capture_session_knowledge' ||
67
+ name === 'record_decision_trace' ||
68
+ name === 'record_knowledge_feedback' ||
69
+ name === 'checkpoint_task_knowledge' ||
55
70
  name === 'publish_personal_project' ||
56
71
  name === 'create_project_relation' ||
57
72
  name === 'review_project_relation' ||
@@ -32,6 +32,30 @@ export function successToolResult(value, { limitBytes = RESULT_LIMIT_BYTES } = {
32
32
  };
33
33
  }
34
34
 
35
+ export function hookAdditionalContextToolResult(
36
+ value,
37
+ {
38
+ hookEventName,
39
+ label,
40
+ limitBytes = RESULT_LIMIT_BYTES
41
+ }
42
+ ) {
43
+ const result = successToolResult(value, { limitBytes });
44
+ const additionalContext = `${label}\n${JSON.stringify(result.structuredContent)}`;
45
+ return {
46
+ ...result,
47
+ content: [{
48
+ type: 'text',
49
+ text: JSON.stringify({
50
+ hookSpecificOutput: {
51
+ hookEventName,
52
+ additionalContext
53
+ }
54
+ })
55
+ }]
56
+ };
57
+ }
58
+
35
59
  export function errorToolResult(error) {
36
60
  const controlled = error instanceof ApplicationError;
37
61
  const code = controlled ? error.code : 'internal_error';
@@ -25,7 +25,7 @@ export function resolveRuntimeOptions(args = [], env = process.env, {
25
25
  let defaultDbPath = systemDbPath;
26
26
  if (cliDbPath === null && envDbPath === null) {
27
27
  defaultDbPath = resolveDefaultDbPath({ cwd, existsSync, systemDbPath });
28
- guardImplicitDefaultUpgrade({ cwd, existsSync, systemDbPath });
28
+ guardImplicitDefaultUpgrade({ cwd, existsSync });
29
29
  }
30
30
  return {
31
31
  dbPath: cliDbPath ?? envDbPath ?? defaultDbPath,
@@ -36,8 +36,7 @@ export function resolveRuntimeOptions(args = [], env = process.env, {
36
36
 
37
37
  export function resolveStore({ dbPath }) {
38
38
  if (extname(dbPath).toLowerCase() === '.json') {
39
- const destination = dbPath.slice(0, -extname(dbPath).length) + '.db';
40
- throw new RuntimeConfigurationError(migrationMessage(dbPath, destination));
39
+ throw new RuntimeConfigurationError(legacyJsonMessage(dbPath));
41
40
  }
42
41
  return openSqliteStore(dbPath);
43
42
  }
@@ -59,17 +58,17 @@ function resolveDefaultDbPath({ cwd, existsSync, systemDbPath }) {
59
58
  return systemDbPath;
60
59
  }
61
60
 
62
- function guardImplicitDefaultUpgrade({ cwd, existsSync, systemDbPath }) {
61
+ function guardImplicitDefaultUpgrade({ cwd, existsSync }) {
63
62
  const legacyPath = resolve(cwd, '.fuli/context.json');
64
63
  const localDatabasePath = resolve(cwd, LEGACY_LOCAL_DB_PATH);
65
64
  if (existsSync(legacyPath) && !existsSync(localDatabasePath)) {
66
- throw new RuntimeConfigurationError(migrationMessage(legacyPath, systemDbPath));
65
+ throw new RuntimeConfigurationError(legacyJsonMessage(legacyPath));
67
66
  }
68
67
  }
69
68
 
70
- function migrationMessage(source, destination) {
71
- return 'JSON is a legacy import format. Run once: node src/cli.js migrate ' +
72
- `--from ${quoteShellArgument(source)} --to ${quoteShellArgument(destination)}`;
69
+ function legacyJsonMessage(source) {
70
+ return `JSON is a retired legacy format (${quoteShellArgument(source)}). ` +
71
+ 'The current Fuli CLI no longer provides SQLite knowledge commands or JSON migration.';
73
72
  }
74
73
 
75
74
  export function openLocalApplication({ dbPath, personalSpaceName = DEFAULT_PERSONAL_SPACE }) {
@@ -0,0 +1,52 @@
1
+ import { isIPv4 } from 'node:net';
2
+ import { networkInterfaces } from 'node:os';
3
+
4
+ export const LAN_ACCESS_USERNAME = 'fuli';
5
+
6
+ export function discoverLanAddresses(interfaces = networkInterfaces()) {
7
+ const addresses = [];
8
+ for (const entries of Object.values(interfaces ?? {})) {
9
+ for (const entry of entries ?? []) {
10
+ if (!entry || entry.internal || !isIpv4Family(entry.family)) continue;
11
+ if (!isPrivateLanIpv4(entry.address)) continue;
12
+ addresses.push(entry.address);
13
+ }
14
+ }
15
+ return [...new Set(addresses)].sort(compareIpv4);
16
+ }
17
+
18
+ export function lanConsoleUrls(addresses, port) {
19
+ return addresses.map((address) => `http://${consoleAuthority(address, port)}`);
20
+ }
21
+
22
+ export function lanServerAuthorities(addresses, port) {
23
+ return lanConsoleUrls(addresses, port).map((url) => new URL(url).host);
24
+ }
25
+
26
+ function isIpv4Family(family) {
27
+ return family === 'IPv4' || family === 4;
28
+ }
29
+
30
+ function isPrivateLanIpv4(address) {
31
+ if (!isIPv4(address)) return false;
32
+ const [first, second] = address.split('.').map(Number);
33
+ return first === 10 ||
34
+ (first === 172 && second >= 16 && second <= 31) ||
35
+ (first === 192 && second === 168) ||
36
+ (first === 169 && second === 254);
37
+ }
38
+
39
+ function consoleAuthority(address, port) {
40
+ return Number(port) === 80 ? address : `${address}:${port}`;
41
+ }
42
+
43
+ function compareIpv4(left, right) {
44
+ const leftParts = left.split('.').map(Number);
45
+ const rightParts = right.split('.').map(Number);
46
+ for (let index = 0; index < 4; index += 1) {
47
+ if (leftParts[index] !== rightParts[index]) {
48
+ return leftParts[index] - rightParts[index];
49
+ }
50
+ }
51
+ return 0;
52
+ }
package/src/server.js CHANGED
@@ -4,11 +4,16 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { handleApiRequest } from './http/api-router.js';
6
6
  import { mapHttpError } from './http/error-mapping.js';
7
- import { localServerAuthority, rejectDisallowedRequest } from './http/request-policy.js';
7
+ import { localServerAuthority, rejectRequestOutsidePolicy } from './http/request-policy.js';
8
8
  import { sendJson } from './http/response.js';
9
9
  import { serveStatic } from './http/static-handler.js';
10
10
  import { DEFAULT_FULI_PORT } from './defaults.js';
11
11
  import { resolveGraphRuntimeOptions } from './graphiti/runtime-config.js';
12
+ import {
13
+ discoverLanAddresses,
14
+ lanConsoleUrls,
15
+ lanServerAuthorities
16
+ } from './server/lan-access.js';
12
17
  import { createServerApplication } from './server/application-lifecycle.js';
13
18
  import { listenServer } from './server/listen.js';
14
19
 
@@ -26,8 +31,19 @@ export async function createServer(options = {}) {
26
31
  store,
27
32
  app,
28
33
  closeApplicationOnShutdown,
29
- isBlockedPort = isFetchBlockedPort
34
+ isBlockedPort = isFetchBlockedPort,
35
+ lan = false,
36
+ lanAccessToken = null,
37
+ lanAddresses: configuredLanAddresses = null
30
38
  } = options;
39
+ const lanEnabled = lan === true;
40
+ const lanToken = lanEnabled ? requiredLanToken(lanAccessToken) : null;
41
+ const lanAddresses = lanEnabled
42
+ ? [...new Set(configuredLanAddresses ?? discoverLanAddresses())]
43
+ : [];
44
+ if (lanEnabled && lanAddresses.length === 0) {
45
+ throw new Error('No private IPv4 LAN address is available');
46
+ }
31
47
  if (Number(port) !== 0 && isBlockedPort(port)) {
32
48
  throw new Error(`Port ${port} is blocked for local fetch`);
33
49
  }
@@ -45,15 +61,23 @@ export async function createServer(options = {}) {
45
61
 
46
62
  for (let attempt = 0; attempt < 20; attempt += 1) {
47
63
  let authority = null;
64
+ let allowedLanAuthorities = [];
48
65
  const server = createHttpServer((request, response) => {
49
- handleRequest({ request, response, app: application, authority }).catch((error) => {
66
+ handleRequest({
67
+ request,
68
+ response,
69
+ app: application,
70
+ authority,
71
+ lanAuthorities: allowedLanAuthorities,
72
+ lanAccessToken: lanToken
73
+ }).catch((error) => {
50
74
  const mapped = mapHttpError(error);
51
75
  sendJson(response, mapped.status, mapped.body);
52
76
  });
53
77
  });
54
78
 
55
79
  try {
56
- await listenServer(server, port);
80
+ await listenServer(server, port, lanEnabled ? '0.0.0.0' : '127.0.0.1');
57
81
  } catch (error) {
58
82
  runtime.close();
59
83
  throw error;
@@ -66,6 +90,9 @@ export async function createServer(options = {}) {
66
90
  throw new Error(`Port ${address.port} is blocked for local fetch`);
67
91
  }
68
92
  authority = localServerAuthority(address);
93
+ allowedLanAuthorities = lanEnabled
94
+ ? lanServerAuthorities(lanAddresses, address.port)
95
+ : [];
69
96
  if (!authority) {
70
97
  await new Promise((resolveClose) => server.close(resolveClose));
71
98
  runtime.close();
@@ -73,7 +100,12 @@ export async function createServer(options = {}) {
73
100
  }
74
101
 
75
102
  server.once('close', runtime.close);
76
- return { server, url: `http://${authority}`, app: application };
103
+ return {
104
+ server,
105
+ url: `http://${authority}`,
106
+ lanUrls: lanEnabled ? lanConsoleUrls(lanAddresses, address.port) : [],
107
+ app: application
108
+ };
77
109
  }
78
110
 
79
111
  runtime.close();
@@ -94,8 +126,21 @@ export function isFetchBlockedPort(port) {
94
126
  return FETCH_BLOCKED_PORTS.has(Number(port));
95
127
  }
96
128
 
97
- async function handleRequest({ request, response, app, authority }) {
98
- if (rejectDisallowedRequest({ request, response, authority })) return;
129
+ async function handleRequest({
130
+ request,
131
+ response,
132
+ app,
133
+ authority,
134
+ lanAuthorities,
135
+ lanAccessToken
136
+ }) {
137
+ if (rejectRequestOutsidePolicy({
138
+ request,
139
+ response,
140
+ authority,
141
+ lanAuthorities,
142
+ lanAccessToken
143
+ })) return;
99
144
  if (await handleApiRequest({ request, response, app })) return;
100
145
  serveStatic(new URL(request.url, 'http://127.0.0.1').pathname, response);
101
146
  }
@@ -110,7 +155,13 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
110
155
  async function runProgram(args) {
111
156
  const runtimeOptions = resolveGraphRuntimeOptions(args, process.env);
112
157
  const port = numberOption(args, '--port', DEFAULT_FULI_PORT);
113
- const { server, url } = await createServer({ ...runtimeOptions, port });
158
+ const lan = args.includes('--lan');
159
+ const { server, url, lanUrls } = await createServer({
160
+ ...runtimeOptions,
161
+ port,
162
+ lan,
163
+ lanAccessToken: lan ? process.env.FULI_LAN_ACCESS_TOKEN : null
164
+ });
114
165
  const handlers = new Map();
115
166
  const close = () => server.close();
116
167
  for (const signal of ['SIGINT', 'SIGTERM']) {
@@ -122,6 +173,14 @@ async function runProgram(args) {
122
173
  for (const [signal, handler] of handlers) process.off(signal, handler);
123
174
  });
124
175
  console.log(`复利工作台 running at ${url}`);
176
+ for (const lanUrl of lanUrls) console.log(`复利工作台 LAN at ${lanUrl}`);
177
+ }
178
+
179
+ function requiredLanToken(value) {
180
+ if (typeof value !== 'string' || value.length < 16) {
181
+ throw new TypeError('LAN mode requires a generated access token');
182
+ }
183
+ return value;
125
184
  }
126
185
 
127
186
  function numberOption(args, flag, fallback) {
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
 
3
3
  import { replaceFuliTable } from './codex-config.js';
4
4
  import { isCodexBootstrapCurrent } from './codex-bootstrap.js';
5
+ import { hasCurrentClaudeCodeHooks } from './claude-code-config.js';
5
6
  import { isSessionSkillCurrent } from './session-skill.js';
6
7
  import { readJsonFile } from '../storage/json-file.js';
7
8
 
@@ -27,9 +28,16 @@ export function inspectAgentInstallations(agents, context, {
27
28
  context,
28
29
  { fileExists, readText }
29
30
  );
31
+ const claudeLifecycleCurrent = agent.id !== 'claude-code' ||
32
+ hasCurrentClaudeCodeHooks(readJson(agent.settingsPath));
30
33
 
31
34
  let integrationStatus = 'not_connected';
32
- if (registration.current && skillsCurrent && codexBootstrapCurrent) {
35
+ if (
36
+ registration.current
37
+ && skillsCurrent
38
+ && codexBootstrapCurrent
39
+ && claudeLifecycleCurrent
40
+ ) {
33
41
  integrationStatus = 'connected';
34
42
  }
35
43
  else if (registration.present || skillsPresent) integrationStatus = 'update_available';
@@ -42,6 +50,9 @@ export function inspectAgentInstallations(agents, context, {
42
50
  skills: skillsCurrent ? 'current' : skillsPresent ? 'outdated' : 'missing',
43
51
  ...(agent.id === 'codex'
44
52
  ? { bootstrap: codexBootstrapCurrent ? 'current' : 'outdated' }
53
+ : {}),
54
+ ...(agent.id === 'claude-code'
55
+ ? { lifecycleHooks: claudeLifecycleCurrent ? 'current' : 'outdated' }
45
56
  : {})
46
57
  }
47
58
  };
@@ -71,7 +82,8 @@ function inspectRegistration(agent, context, readers) {
71
82
  present,
72
83
  current: present &&
73
84
  server.command === expected.command &&
74
- sameStrings(server.args, expected.args)
85
+ sameStrings(server.args, expected.args) &&
86
+ (agent.id !== 'claude-code' || server.alwaysLoad === true)
75
87
  };
76
88
  } catch {
77
89
  return { present: false, current: false };
@@ -3,6 +3,10 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import {
7
+ connectClaudeCode,
8
+ disconnectClaudeCode
9
+ } from './claude-code-config.js';
6
10
  import { connectCursor, disconnectCursor } from './cursor-config.js';
7
11
  import { connectCodex, disconnectCodex } from './codex-config.js';
8
12
 
@@ -34,6 +38,7 @@ export function discoverAgents({
34
38
  label: 'Claude Code',
35
39
  command: 'claude',
36
40
  configPath: pathApi.join(homeDir, '.claude.json'),
41
+ settingsPath: pathApi.join(homeDir, '.claude', 'settings.json'),
37
42
  skillPath: pathApi.join(homeDir, '.claude', 'skills', skillName),
38
43
  projectSkillPath: pathApi.join(homeDir, '.claude', 'skills', projectSkillName),
39
44
  available: Boolean(commandExists('claude'))
@@ -75,9 +80,13 @@ export function buildAgentCommands(agent, context) {
75
80
  export function connectAgent(agent, context, {
76
81
  runCommand = defaultRunCommand,
77
82
  connectCodexConfig = connectCodex,
83
+ connectClaudeCodeConfig = connectClaudeCode,
78
84
  connectCursorConfig = connectCursor
79
85
  } = {}) {
80
86
  if (agent.id === 'codex') return connectCodexConfig(agent, context);
87
+ if (agent.id === 'claude-code') {
88
+ return connectClaudeCodeConfig(agent, context);
89
+ }
81
90
  if (agent.id === 'cursor') return connectCursorConfig(agent, context);
82
91
 
83
92
  const commands = buildAgentCommands(agent, context);
@@ -91,10 +100,12 @@ export function connectAgent(agent, context, {
91
100
 
92
101
  export function disconnectAgent(agent, {
93
102
  disconnectCodexConfig = disconnectCodex,
103
+ disconnectClaudeCodeConfig = disconnectClaudeCode,
94
104
  disconnectJsonConfig = disconnectCursor
95
105
  } = {}) {
96
106
  if (agent.id === 'codex') return disconnectCodexConfig(agent);
97
- if (agent.id === 'cursor' || agent.id === 'claude-code') {
107
+ if (agent.id === 'claude-code') return disconnectClaudeCodeConfig(agent);
108
+ if (agent.id === 'cursor') {
98
109
  return disconnectJsonConfig(agent);
99
110
  }
100
111
  throw new TypeError(`Unsupported agent: ${agent.id}`);