send-context 0.1.0 → 0.1.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.
- package/README.md +54 -20
- package/dist/commands/export.js +56 -13
- package/dist/core/distiller.js +114 -0
- package/dist/index.js +3 -1
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
> Relay an AI coding-agent session from one developer to another through an encrypted, ephemeral link.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/send-context)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
5
8
|
`send-context` is an agent-agnostic CLI for passing live context between AI coding agents — across machines, across people, across tools. A developer in one timezone exports their session; a teammate in another runs one command to pick up exactly where they left off, with the context injected straight into *their* agent.
|
|
6
9
|
|
|
7
10
|
The session is distilled into a structured **Context Handoff Skill** document, encrypted on your machine, and stored behind a short-lived link. The transport layer only ever sees ciphertext.
|
|
8
11
|
|
|
9
12
|
```
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
│ send-context export
|
|
16
|
-
|
|
13
|
+
pi · Claude Code · OpenCode pi · Claude Code · OpenCode
|
|
14
|
+
│ ▲
|
|
15
|
+
│ extract + format inject prompt │
|
|
16
|
+
▼ │
|
|
17
|
+
┌───────────────────────┐ encrypt decrypt ┌────────────────────────┐
|
|
18
|
+
│ send-context export │ ────────► edge KV (24h) ────────► │ send-context receive │
|
|
19
|
+
└───────────────────────┘ (ciphertext only) └────────────────────────┘
|
|
17
20
|
```
|
|
18
21
|
|
|
19
22
|
## Features
|
|
@@ -27,7 +30,7 @@ The session is distilled into a structured **Context Handoff Skill** document, e
|
|
|
27
30
|
|
|
28
31
|
## How it works
|
|
29
32
|
|
|
30
|
-
1. **Export** detects the active agent
|
|
33
|
+
1. **Export** detects the active agent and extracts its session. With a `GEMINI_API_KEY` set, it distills the session into the handoff brief automatically; otherwise it guides you through writing the brief and picking which raw messages to attach.
|
|
31
34
|
2. The brief is rendered into the Context Handoff Skill template, encrypted with a password you choose, and uploaded. You get a `send-context://` link.
|
|
32
35
|
3. **Receive** downloads the blob, decrypts it locally, wraps it in an injection prompt, and spawns the receiving agent with that prompt as its opening message.
|
|
33
36
|
|
|
@@ -40,22 +43,33 @@ The session is distilled into a structured **Context Handoff Skill** document, e
|
|
|
40
43
|
|
|
41
44
|
- Node.js 20+
|
|
42
45
|
- One of: `pi`, `claude`, or `opencode` with at least one session in the project directory
|
|
46
|
+
- A Google Gemini API key (optional) — set `GEMINI_API_KEY` to auto-distill sessions instead of writing the brief by hand
|
|
43
47
|
- [Deno](https://deno.com) — only if you want to deploy or run the transport worker yourself
|
|
44
48
|
|
|
45
49
|
### Install
|
|
46
50
|
|
|
47
51
|
```bash
|
|
48
|
-
npm install
|
|
49
|
-
|
|
50
|
-
node dist/index.js --help
|
|
52
|
+
npm install -g send-context
|
|
53
|
+
send-context --help
|
|
51
54
|
```
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
Or run it without installing:
|
|
54
57
|
|
|
55
58
|
```bash
|
|
56
|
-
|
|
59
|
+
npx send-context --help
|
|
57
60
|
```
|
|
58
61
|
|
|
62
|
+
<details>
|
|
63
|
+
<summary>From source</summary>
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/shafiqimtiaz/context-handoff.git
|
|
67
|
+
cd context-handoff
|
|
68
|
+
npm install && npm run build
|
|
69
|
+
node dist/index.js --help
|
|
70
|
+
```
|
|
71
|
+
</details>
|
|
72
|
+
|
|
59
73
|
## Deploy the transport
|
|
60
74
|
|
|
61
75
|
The transport runs on **Deno Deploy + Deno KV** (`worker/main.ts`). It stores only encrypted payloads, each with a native 24-hour TTL.
|
|
@@ -82,27 +96,46 @@ deno task deploy # deploys --prod, prints your *.deno.net host
|
|
|
82
96
|
### Send a context handoff
|
|
83
97
|
|
|
84
98
|
```bash
|
|
85
|
-
SEND_CONTEXT_WORKER=your-project.deno.net
|
|
99
|
+
SEND_CONTEXT_WORKER=your-project.deno.net send-context export
|
|
86
100
|
# or pass the host and agent explicitly:
|
|
87
|
-
|
|
101
|
+
send-context export --worker your-project.deno.net --agent pi
|
|
88
102
|
```
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
Without a Gemini key, you'll be guided through picking the agent, writing the brief, curating the appendix, and setting a password — see [Distill with Gemini](#distill-with-gemini-recommended) to automate the brief. The command prints a link:
|
|
91
105
|
|
|
92
106
|
```
|
|
93
107
|
send-context://your-project.deno.net/<id>#<password>
|
|
94
108
|
```
|
|
95
109
|
|
|
110
|
+
#### Distill with Gemini (recommended)
|
|
111
|
+
|
|
112
|
+
Raw sessions are noisy. Set `GEMINI_API_KEY` and `export` runs the session through Gemini first, which distills it into the five handoff sections automatically and drops the raw appendix — so the receiver gets a dense brief, not a chat log. The manual section/appendix prompts are skipped.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
GEMINI_API_KEY=… SEND_CONTEXT_WORKER=your-project.deno.net send-context export
|
|
116
|
+
# optional: override the model (default gemini-2.5-flash)
|
|
117
|
+
GEMINI_MODEL=gemini-2.5-pro GEMINI_API_KEY=… send-context export
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If the key is absent or the call fails, `export` falls back to the manual flow — Gemini is an enhancement, not a hard dependency. The key never leaves your machine; only the encrypted, distilled brief is uploaded. It uses Google's OpenAI-compatible endpoint, so no extra SDK is installed.
|
|
121
|
+
|
|
122
|
+
Set `SEND_CONTEXT_PASSWORD` to skip the password prompt too. With Gemini distillation on and a single detected agent, that makes `export` fully non-interactive — no TTY needed:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
GEMINI_API_KEY=… SEND_CONTEXT_PASSWORD=… \
|
|
126
|
+
SEND_CONTEXT_WORKER=your-project.deno.net send-context export --agent pi
|
|
127
|
+
```
|
|
128
|
+
|
|
96
129
|
### Receive a context handoff
|
|
97
130
|
|
|
98
131
|
```bash
|
|
99
132
|
# Launch an agent with the context injected:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
send-context receive 'send-context://…/<id>#<password>' -- pi "continue"
|
|
134
|
+
send-context receive 'send-context://…/<id>#<password>' -- claude "continue"
|
|
135
|
+
send-context receive 'send-context://…/<id>#<password>' -- opencode run "continue"
|
|
103
136
|
|
|
104
137
|
# Or just print the decrypted context handoff document:
|
|
105
|
-
|
|
138
|
+
send-context receive 'send-context://…/<id>#<password>'
|
|
106
139
|
```
|
|
107
140
|
|
|
108
141
|
## Supported agents
|
|
@@ -150,4 +183,5 @@ worker/
|
|
|
150
183
|
|
|
151
184
|
- **CLI:** TypeScript, [commander](https://github.com/tj/commander.js), [@clack/prompts](https://github.com/bombshell-dev/clack)
|
|
152
185
|
- **Crypto:** Node.js built-in `crypto` (AES-256-GCM, scrypt)
|
|
186
|
+
- **Distillation (optional):** Google Gemini via its OpenAI-compatible Chat Completions endpoint
|
|
153
187
|
- **Transport:** Deno Deploy + Deno KV
|
package/dist/commands/export.js
CHANGED
|
@@ -38,6 +38,7 @@ const p = __importStar(require("@clack/prompts"));
|
|
|
38
38
|
const index_js_1 = require("../adapters/index.js");
|
|
39
39
|
const types_js_1 = require("../adapters/types.js");
|
|
40
40
|
const formatter_js_1 = require("../core/formatter.js");
|
|
41
|
+
const distiller_js_1 = require("../core/distiller.js");
|
|
41
42
|
const crypto_js_1 = require("../core/crypto.js");
|
|
42
43
|
const transport_js_1 = require("../core/transport.js");
|
|
43
44
|
const link_js_1 = require("../core/link.js");
|
|
@@ -71,12 +72,30 @@ async function runExport(opts) {
|
|
|
71
72
|
process.exitCode = 1;
|
|
72
73
|
return;
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
let sections;
|
|
76
|
+
let appendix;
|
|
77
|
+
if ((0, distiller_js_1.geminiAvailable)()) {
|
|
78
|
+
spin.start("Distilling session with Gemini");
|
|
79
|
+
try {
|
|
80
|
+
sections = await (0, distiller_js_1.distillSession)(messages);
|
|
81
|
+
appendix = [];
|
|
82
|
+
spin.stop("Distilled into a handoff brief.");
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
spin.stop("Distillation failed — falling back to manual.");
|
|
86
|
+
p.log.warn(String(err.message));
|
|
87
|
+
const manual = await runManual(messages);
|
|
88
|
+
if (manual === null)
|
|
89
|
+
return;
|
|
90
|
+
({ sections, appendix } = manual);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const manual = await runManual(messages);
|
|
95
|
+
if (manual === null)
|
|
96
|
+
return;
|
|
97
|
+
({ sections, appendix } = manual);
|
|
98
|
+
}
|
|
80
99
|
const markdown = (0, formatter_js_1.formatToHandoffSkill)({
|
|
81
100
|
sourceAgent: adapter.getName(),
|
|
82
101
|
timestamp: new Date().toISOString(),
|
|
@@ -84,14 +103,9 @@ async function runExport(opts) {
|
|
|
84
103
|
appendix,
|
|
85
104
|
sections,
|
|
86
105
|
});
|
|
87
|
-
const password = await
|
|
88
|
-
|
|
89
|
-
validate: (v) => (v.length < 4 ? "Use at least 4 characters." : undefined),
|
|
90
|
-
});
|
|
91
|
-
if (p.isCancel(password)) {
|
|
92
|
-
cancelled();
|
|
106
|
+
const password = await resolvePassword();
|
|
107
|
+
if (password === null)
|
|
93
108
|
return;
|
|
94
|
-
}
|
|
95
109
|
const payload = (0, crypto_js_1.encrypt)(markdown, password);
|
|
96
110
|
spin.start("Uploading encrypted handoff");
|
|
97
111
|
let id;
|
|
@@ -136,6 +150,35 @@ async function resolveAgent(preset) {
|
|
|
136
150
|
}
|
|
137
151
|
return choice;
|
|
138
152
|
}
|
|
153
|
+
async function resolvePassword() {
|
|
154
|
+
const fromEnv = process.env.SEND_CONTEXT_PASSWORD;
|
|
155
|
+
if (fromEnv) {
|
|
156
|
+
if (fromEnv.length < 4) {
|
|
157
|
+
p.cancel("SEND_CONTEXT_PASSWORD must be at least 4 characters.");
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return fromEnv;
|
|
162
|
+
}
|
|
163
|
+
const password = await p.password({
|
|
164
|
+
message: "Set a password (the receiver needs it to decrypt):",
|
|
165
|
+
validate: (v) => (v.length < 4 ? "Use at least 4 characters." : undefined),
|
|
166
|
+
});
|
|
167
|
+
if (p.isCancel(password)) {
|
|
168
|
+
cancelled();
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return password;
|
|
172
|
+
}
|
|
173
|
+
async function runManual(messages) {
|
|
174
|
+
const sections = await promptSections();
|
|
175
|
+
if (sections === null)
|
|
176
|
+
return null;
|
|
177
|
+
const appendix = await curateAppendix(messages);
|
|
178
|
+
if (appendix === null)
|
|
179
|
+
return null;
|
|
180
|
+
return { sections, appendix };
|
|
181
|
+
}
|
|
139
182
|
async function promptSections() {
|
|
140
183
|
const wants = await p.confirm({
|
|
141
184
|
message: "Add a written summary (objective, blockers, next steps)? Recommended.",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.geminiAvailable = geminiAvailable;
|
|
4
|
+
exports.distillSession = distillSession;
|
|
5
|
+
const jsonrepair_1 = require("jsonrepair");
|
|
6
|
+
/**
|
|
7
|
+
* Optional Gemini pass that distills a raw session into the structured
|
|
8
|
+
* Context Handoff sections, so the sender ships a dense brief instead of
|
|
9
|
+
* noisy chat logs. Uses Google's OpenAI-compatible Chat Completions endpoint,
|
|
10
|
+
* so no SDK is needed — just Node's built-in fetch.
|
|
11
|
+
*/
|
|
12
|
+
const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
|
|
13
|
+
const DEFAULT_MODEL = "gemini-2.5-flash";
|
|
14
|
+
const SYSTEM_PROMPT = `You distill an AI coding-agent session into a dense handoff brief for another developer's agent. Strip conversational noise; keep only what the receiving agent needs to continue. Be concrete and terse.
|
|
15
|
+
|
|
16
|
+
Respond with ONLY a JSON object of this exact shape:
|
|
17
|
+
{
|
|
18
|
+
"objective": "the primary goal, one or two sentences",
|
|
19
|
+
"currentState": "where things stand right now and any blockers",
|
|
20
|
+
"completedSteps": "what is already done — one '-' bullet per line",
|
|
21
|
+
"failedApproaches": "approaches that were tried and did not work and must not be retried — '-' bullets, or 'None.'",
|
|
22
|
+
"nextSteps": "the concrete next actions the receiving agent should take — '-' bullets"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Leave a field as an empty string only when the session genuinely lacks that information.`;
|
|
26
|
+
function geminiAvailable() {
|
|
27
|
+
return Boolean(process.env.GEMINI_API_KEY);
|
|
28
|
+
}
|
|
29
|
+
async function distillSession(messages) {
|
|
30
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
31
|
+
if (!apiKey)
|
|
32
|
+
throw new Error("GEMINI_API_KEY is not set.");
|
|
33
|
+
const model = process.env.GEMINI_MODEL ?? DEFAULT_MODEL;
|
|
34
|
+
const transcript = messages
|
|
35
|
+
.map((m) => `### ${m.role.toUpperCase()}\n${m.content.trim()}`)
|
|
36
|
+
.join("\n\n");
|
|
37
|
+
const res = await fetch(GEMINI_ENDPOINT, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
Authorization: `Bearer ${apiKey}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model,
|
|
45
|
+
messages: [
|
|
46
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
47
|
+
{ role: "user", content: `Distill this session:\n\n${transcript}` },
|
|
48
|
+
],
|
|
49
|
+
response_format: { type: "json_object" },
|
|
50
|
+
temperature: 0.2,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`Gemini request failed (${res.status}): ${await res.text()}`);
|
|
55
|
+
}
|
|
56
|
+
const data = (await res.json());
|
|
57
|
+
const content = data.choices?.[0]?.message?.content;
|
|
58
|
+
if (!content)
|
|
59
|
+
throw new Error("Gemini returned no content.");
|
|
60
|
+
return parseSections(content);
|
|
61
|
+
}
|
|
62
|
+
function parseSections(content) {
|
|
63
|
+
const obj = JSON.parse(extractJson(content));
|
|
64
|
+
return {
|
|
65
|
+
objective: str(obj.objective),
|
|
66
|
+
currentState: str(obj.currentState),
|
|
67
|
+
completedSteps: str(obj.completedSteps),
|
|
68
|
+
failedApproaches: str(obj.failedApproaches),
|
|
69
|
+
nextSteps: str(obj.nextSteps),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function extractJson(content) {
|
|
73
|
+
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
74
|
+
const body = fenced ? fenced[1].trim() : content;
|
|
75
|
+
// Isolate the JSON from any surrounding prose, then let jsonrepair fix the
|
|
76
|
+
// common ways models still mangle it: trailing commas, single quotes,
|
|
77
|
+
// unquoted keys, truncation (missing closing brackets), and so on.
|
|
78
|
+
const start = body.indexOf("{");
|
|
79
|
+
const candidate = start === -1 ? body : (firstBalancedObject(body) ?? body.slice(start));
|
|
80
|
+
return (0, jsonrepair_1.jsonrepair)(candidate);
|
|
81
|
+
}
|
|
82
|
+
// Return the first brace-balanced JSON object, ignoring any prose or extra
|
|
83
|
+
// objects the model appends after it (thinking models often do). Tracks string
|
|
84
|
+
// literals and escapes so braces inside strings don't throw off the depth count.
|
|
85
|
+
function firstBalancedObject(text) {
|
|
86
|
+
const start = text.indexOf("{");
|
|
87
|
+
if (start === -1)
|
|
88
|
+
return null;
|
|
89
|
+
let depth = 0;
|
|
90
|
+
let inString = false;
|
|
91
|
+
let escaped = false;
|
|
92
|
+
for (let i = start; i < text.length; i++) {
|
|
93
|
+
const ch = text[i];
|
|
94
|
+
if (inString) {
|
|
95
|
+
if (escaped)
|
|
96
|
+
escaped = false;
|
|
97
|
+
else if (ch === "\\")
|
|
98
|
+
escaped = true;
|
|
99
|
+
else if (ch === '"')
|
|
100
|
+
inString = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === '"')
|
|
104
|
+
inString = true;
|
|
105
|
+
else if (ch === "{")
|
|
106
|
+
depth++;
|
|
107
|
+
else if (ch === "}" && --depth === 0)
|
|
108
|
+
return text.slice(start, i + 1);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function str(v) {
|
|
113
|
+
return typeof v === "string" ? v.trim() : "";
|
|
114
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,12 @@ const commander_1 = require("commander");
|
|
|
5
5
|
const export_js_1 = require("./commands/export.js");
|
|
6
6
|
const receive_js_1 = require("./commands/receive.js");
|
|
7
7
|
const program = new commander_1.Command();
|
|
8
|
+
// Single source of truth: read the version release-it bumps in package.json.
|
|
9
|
+
const { version } = require("../package.json");
|
|
8
10
|
program
|
|
9
11
|
.name("send-context")
|
|
10
12
|
.description("Relay AI coding-agent session context between developers via an encrypted, ephemeral link.")
|
|
11
|
-
.version(
|
|
13
|
+
.version(version)
|
|
12
14
|
.enablePositionalOptions();
|
|
13
15
|
program
|
|
14
16
|
.command("export")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "send-context",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Agent-agnostic CLI to relay AI coding-agent session context between developers via an encrypted, ephemeral edge link.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -35,17 +35,20 @@
|
|
|
35
35
|
"dev": "tsx src/index.ts",
|
|
36
36
|
"start": "node dist/index.js",
|
|
37
37
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
-
"prepublishOnly": "npm run build"
|
|
38
|
+
"prepublishOnly": "npm run build",
|
|
39
|
+
"release": "release-it"
|
|
39
40
|
},
|
|
40
41
|
"engines": {
|
|
41
42
|
"node": ">=20"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@clack/prompts": "^0.7.0",
|
|
45
|
-
"commander": "^12.1.0"
|
|
46
|
+
"commander": "^12.1.0",
|
|
47
|
+
"jsonrepair": "^3.14.0"
|
|
46
48
|
},
|
|
47
49
|
"devDependencies": {
|
|
48
50
|
"@types/node": "^22.0.0",
|
|
51
|
+
"release-it": "^20.2.0",
|
|
49
52
|
"tsx": "^4.19.0",
|
|
50
53
|
"typescript": "^5.6.0"
|
|
51
54
|
}
|