ilink-bridge-profile 0.1.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.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # ilink-bridge-profile (Node.js)
2
+
3
+ Node.js SDK for writing [iLink Hub Bridge](../../docs/bridge/profile-spec.md) profile handlers.
4
+
5
+ Implements the **P0 exec protocol** — reads `ILINK_*` env vars injected by the bridge,
6
+ calls your async handler, writes P0-formatted output to stdout. Works on macOS, Linux, and Windows.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install ilink-bridge-profile
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```js
17
+ // my-profile.js
18
+ const { createProfile } = require('ilink-bridge-profile');
19
+
20
+ createProfile(async ({ message, sessionId, sessionName }) => {
21
+ // call any LLM API here
22
+ const reply = await myLLM(message);
23
+ return { response: reply };
24
+ });
25
+ ```
26
+
27
+ Configure in `ilink-hub-bridge.yaml`:
28
+
29
+ ```yaml
30
+ profiles:
31
+ my-ai:
32
+ command: node
33
+ args: [/path/to/my-profile.js]
34
+ stdin: none
35
+ timeout_secs: 120
36
+ ```
37
+
38
+ ## With session continuity
39
+
40
+ ```js
41
+ const { createProfile, loadHistory, appendHistory } = require('ilink-bridge-profile');
42
+
43
+ createProfile(async ({ message, sessionId }) => {
44
+ const history = loadHistory(sessionId);
45
+
46
+ const messages = [
47
+ ...history.map(e => ({ role: e.role, content: e.content })),
48
+ { role: 'user', content: message },
49
+ ];
50
+
51
+ const reply = await callOpenAI(messages);
52
+
53
+ appendHistory(sessionId, [
54
+ { role: 'user', content: message },
55
+ { role: 'assistant', content: reply },
56
+ ]);
57
+
58
+ return { response: reply, sessionId };
59
+ });
60
+ ```
61
+
62
+ History is stored in `~/.ilink-hub/sessions/<sessionId>.jsonl` (one JSON object per line).
63
+
64
+ ## API
65
+
66
+ ### `createProfile(handler)`
67
+
68
+ Runs the P0 protocol loop: reads env vars → calls `handler(ctx)` → writes stdout → exits.
69
+
70
+ **`ctx`** fields:
71
+ | Field | Env var | Description |
72
+ |-------|---------|-------------|
73
+ | `message` | `ILINK_MESSAGE` | User message text |
74
+ | `sessionId` | `ILINK_SESSION_ID` | Hub-persisted backend session UUID |
75
+ | `sessionName` | `ILINK_SESSION_NAME` | Human-readable session name |
76
+ | `fromUser` | `ILINK_FROM_USER` | Sender user ID |
77
+ | `contextToken` | `ILINK_CONTEXT_TOKEN` | Hub context token |
78
+
79
+ **Return value**: `{ response: string, sessionId?: string }` or plain `string`.
80
+
81
+ ### `loadHistory(sessionId, sessionDir?)`
82
+
83
+ Load conversation history from `~/.ilink-hub/sessions/<sessionId>.jsonl`.
84
+
85
+ ### `appendHistory(sessionId, entries, sessionDir?)`
86
+
87
+ Append `[{ role, content, ts? }]` entries to the JSONL history file.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "ilink-bridge-profile",
3
+ "version": "0.1.0",
4
+ "description": "iLink Hub Bridge Profile SDK — write one async function, get a cross-platform P0-compliant profile",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "scripts": {
8
+ "test": "node --test src/index.test.js"
9
+ },
10
+ "keywords": ["ilink-hub", "bridge", "profile", "claude", "weixin"],
11
+ "license": "MIT",
12
+ "engines": {
13
+ "node": ">=18"
14
+ }
15
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ export interface ProfileContext {
2
+ /** User message text (ILINK_MESSAGE) */
3
+ message: string;
4
+ /** Hub-persisted backend session UUID (ILINK_SESSION_ID) */
5
+ sessionId: string;
6
+ /** Human-readable session name (ILINK_SESSION_NAME) */
7
+ sessionName: string;
8
+ /** Sender user ID (ILINK_FROM_USER) */
9
+ fromUser: string;
10
+ /** Hub context token (ILINK_CONTEXT_TOKEN) */
11
+ contextToken: string;
12
+ }
13
+
14
+ export interface ProfileResult {
15
+ /** Reply text to send back to the WeChat user */
16
+ response: string;
17
+ /** New backend session ID to persist (optional) */
18
+ sessionId?: string;
19
+ }
20
+
21
+ export type ProfileHandler = (
22
+ ctx: ProfileContext
23
+ ) => Promise<ProfileResult | string>;
24
+
25
+ /**
26
+ * Run a profile handler following the P0 exec protocol.
27
+ * Reads ILINK_* env vars, calls `handler`, writes P0 stdout, then exits.
28
+ */
29
+ export function createProfile(handler: ProfileHandler): void;
30
+
31
+ export interface HistoryEntry {
32
+ role: 'user' | 'assistant' | string;
33
+ content: string;
34
+ ts: string;
35
+ }
36
+
37
+ /**
38
+ * Load conversation history for a session from its JSONL file.
39
+ * Returns an empty array if the file does not exist.
40
+ */
41
+ export function loadHistory(sessionId: string, sessionDir?: string): HistoryEntry[];
42
+
43
+ /**
44
+ * Append entries to a session's JSONL history file.
45
+ * Creates the file (and parent directory) if needed.
46
+ */
47
+ export function appendHistory(
48
+ sessionId: string,
49
+ entries: Omit<HistoryEntry, 'ts'>[],
50
+ sessionDir?: string
51
+ ): void;
52
+
53
+ /** Resolved path for a session JSONL file. */
54
+ export function sessionFilePath(sessionId: string, sessionDir?: string): string;
package/src/index.js ADDED
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+ /**
3
+ * ilink-bridge-profile — iLink Hub Bridge Profile SDK (Node.js)
4
+ *
5
+ * Implements the P0 exec protocol so you can write a single async handler function
6
+ * instead of manually reading env vars and formatting stdout.
7
+ *
8
+ * P0 contract (read by the bridge):
9
+ * Input — env vars: ILINK_MESSAGE, ILINK_SESSION_ID, ILINK_SESSION_NAME,
10
+ * ILINK_FROM_USER, ILINK_CONTEXT_TOKEN
11
+ * Output — stdout: optional first line "ILINK_SESSION:<uuid>", then reply text
12
+ * Exit — 0 = success, non-zero = error
13
+ *
14
+ * @example
15
+ * // my-profile.js
16
+ * const { createProfile } = require('ilink-bridge-profile');
17
+ *
18
+ * createProfile(async ({ message, sessionId, sessionName, fromUser }) => {
19
+ * const reply = await myAI(message);
20
+ * return { response: reply, sessionId: newSessionId };
21
+ * });
22
+ */
23
+
24
+ const path = require('path');
25
+ const os = require('os');
26
+ const fs = require('fs');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Context — passed to the handler function
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * @typedef {Object} ProfileContext
34
+ * @property {string} message - User message text (ILINK_MESSAGE)
35
+ * @property {string} sessionId - Hub-persisted backend session UUID (ILINK_SESSION_ID)
36
+ * @property {string} sessionName - Human-readable session name (ILINK_SESSION_NAME)
37
+ * @property {string} fromUser - Sender user ID (ILINK_FROM_USER)
38
+ * @property {string} contextToken - Hub context token (ILINK_CONTEXT_TOKEN)
39
+ */
40
+
41
+ /**
42
+ * @typedef {Object} ProfileResult
43
+ * @property {string} response - Reply text to send back to the WeChat user
44
+ * @property {string|undefined} sessionId - New backend session ID to persist (optional)
45
+ */
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Session history helpers (optional — for SDK users calling LLM APIs directly)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Default directory for session history files.
53
+ * @returns {string}
54
+ */
55
+ function defaultSessionDir() {
56
+ return path.join(os.homedir(), '.ilink-hub', 'sessions');
57
+ }
58
+
59
+ /**
60
+ * Path for a session JSONL file, keyed by the stable session UUID.
61
+ * @param {string} sessionId
62
+ * @param {string} [sessionDir]
63
+ * @returns {string}
64
+ */
65
+ function sessionFilePath(sessionId, sessionDir) {
66
+ const dir = sessionDir || defaultSessionDir();
67
+ return path.join(dir, `${sessionId}.jsonl`);
68
+ }
69
+
70
+ /**
71
+ * Load conversation history for a session from its JSONL file.
72
+ * Returns an empty array if the file does not exist.
73
+ *
74
+ * @param {string} sessionId
75
+ * @param {string} [sessionDir]
76
+ * @returns {{ role: string, content: string, ts: string }[]}
77
+ */
78
+ function loadHistory(sessionId, sessionDir) {
79
+ if (!sessionId) return [];
80
+ const file = sessionFilePath(sessionId, sessionDir);
81
+ if (!fs.existsSync(file)) return [];
82
+ return fs
83
+ .readFileSync(file, 'utf8')
84
+ .split('\n')
85
+ .filter(Boolean)
86
+ .map((line) => {
87
+ try { return JSON.parse(line); }
88
+ catch { return null; }
89
+ })
90
+ .filter(Boolean);
91
+ }
92
+
93
+ /**
94
+ * Append one or more entries to a session's JSONL history file.
95
+ * Creates the file (and parent directory) if it does not exist.
96
+ *
97
+ * @param {string} sessionId
98
+ * @param {{ role: string, content: string, ts?: string }[]} entries
99
+ * @param {string} [sessionDir]
100
+ */
101
+ function appendHistory(sessionId, entries, sessionDir) {
102
+ if (!sessionId || !entries.length) return;
103
+ const file = sessionFilePath(sessionId, sessionDir);
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ const lines = entries.map((e) =>
106
+ JSON.stringify({ role: e.role, content: e.content, ts: e.ts || new Date().toISOString() })
107
+ );
108
+ fs.appendFileSync(file, lines.join('\n') + '\n', 'utf8');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // createProfile — main entry point
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Run a profile handler following the P0 exec protocol.
117
+ *
118
+ * Reads ILINK_* env vars, invokes `handler(ctx)`, writes the P0 output to stdout,
119
+ * and exits the process with code 0 (success) or 1 (error).
120
+ *
121
+ * @param {(ctx: ProfileContext) => Promise<ProfileResult | string>} handler
122
+ * Async function that receives the profile context and returns either:
123
+ * - A `ProfileResult` object: `{ response, sessionId? }`
124
+ * - A plain string (treated as the response; no session ID update)
125
+ */
126
+ function createProfile(handler) {
127
+ const ctx = {
128
+ message: process.env.ILINK_MESSAGE || '',
129
+ sessionId: process.env.ILINK_SESSION_ID || '',
130
+ sessionName: process.env.ILINK_SESSION_NAME || 'default',
131
+ fromUser: process.env.ILINK_FROM_USER || '',
132
+ contextToken: process.env.ILINK_CONTEXT_TOKEN || '',
133
+ };
134
+
135
+ Promise.resolve()
136
+ .then(() => handler(ctx))
137
+ .then((result) => {
138
+ let response, newSessionId;
139
+
140
+ if (typeof result === 'string') {
141
+ response = result;
142
+ } else {
143
+ response = result.response || '';
144
+ newSessionId = result.sessionId;
145
+ }
146
+
147
+ // P0 output: optional session line first, then reply text
148
+ if (newSessionId) {
149
+ process.stdout.write(`ILINK_SESSION:${newSessionId}\n`);
150
+ }
151
+ process.stdout.write(response);
152
+ process.exit(0);
153
+ })
154
+ .catch((err) => {
155
+ process.stderr.write(`[ilink-hub/profile] handler error: ${err?.stack || err}\n`);
156
+ process.exit(1);
157
+ });
158
+ }
159
+
160
+ module.exports = { createProfile, loadHistory, appendHistory, sessionFilePath };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ const { describe, it } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+
8
+ const { loadHistory, appendHistory, sessionFilePath } = require('./index.js');
9
+
10
+ describe('sessionFilePath', () => {
11
+ it('uses default dir when sessionDir omitted', () => {
12
+ const p = sessionFilePath('abc-123');
13
+ assert.ok(p.includes(path.join('.ilink-hub', 'sessions', 'abc-123.jsonl')));
14
+ });
15
+
16
+ it('uses custom dir when provided', () => {
17
+ const p = sessionFilePath('abc-123', '/tmp/test-sessions');
18
+ assert.equal(p, '/tmp/test-sessions/abc-123.jsonl');
19
+ });
20
+ });
21
+
22
+ describe('loadHistory / appendHistory', () => {
23
+ it('returns empty array for missing file', () => {
24
+ const result = loadHistory('nonexistent-uuid-xyz', '/tmp/no-such-dir');
25
+ assert.deepEqual(result, []);
26
+ });
27
+
28
+ it('round-trips history entries via JSONL', () => {
29
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ilink-test-'));
30
+ const sid = 'test-session-1';
31
+
32
+ appendHistory(sid, [
33
+ { role: 'user', content: 'hello', ts: '2026-01-01T00:00:00Z' },
34
+ { role: 'assistant', content: 'hi there', ts: '2026-01-01T00:00:01Z' },
35
+ ], tmpDir);
36
+
37
+ const entries = loadHistory(sid, tmpDir);
38
+ assert.equal(entries.length, 2);
39
+ assert.equal(entries[0].role, 'user');
40
+ assert.equal(entries[0].content, 'hello');
41
+ assert.equal(entries[1].role, 'assistant');
42
+ assert.equal(entries[1].content, 'hi there');
43
+ });
44
+
45
+ it('appends to existing file across multiple calls', () => {
46
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ilink-test-'));
47
+ const sid = 'test-session-2';
48
+
49
+ appendHistory(sid, [{ role: 'user', content: 'msg1' }], tmpDir);
50
+ appendHistory(sid, [{ role: 'assistant', content: 'reply1' }], tmpDir);
51
+
52
+ const entries = loadHistory(sid, tmpDir);
53
+ assert.equal(entries.length, 2);
54
+ assert.equal(entries[0].content, 'msg1');
55
+ assert.equal(entries[1].content, 'reply1');
56
+ });
57
+ });