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 +87 -0
- package/package.json +15 -0
- package/src/index.d.ts +54 -0
- package/src/index.js +160 -0
- package/src/index.test.js +57 -0
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
|
+
});
|