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 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
+ [![npm version](https://img.shields.io/npm/v/send-context.svg)](https://www.npmjs.com/package/send-context)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./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
- pi / Claude Code / OpenCode pi / Claude Code / OpenCode
11
-
12
- │ extract + format inject prompt │
13
-
14
- ┌─────────────────┐ encrypt send-context://… decrypt ┌─────────────────┐
15
- │ send-context export ───────────► edge KV (24h) ────────► │ send-context receive
16
- └─────────────────┘ (ciphertext only) └─────────────────┘
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, extracts its session, and lets you curate what to send. You add a short written brief and pick which raw messages to attach.
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
- npm run build # compiles src/ to dist/
50
- node dist/index.js --help
52
+ npm install -g send-context
53
+ send-context --help
51
54
  ```
52
55
 
53
- To install the `send-context` command globally:
56
+ Or run it without installing:
54
57
 
55
58
  ```bash
56
- npm install -g .
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 node dist/index.js export
99
+ SEND_CONTEXT_WORKER=your-project.deno.net send-context export
86
100
  # or pass the host and agent explicitly:
87
- node dist/index.js export --worker your-project.deno.net --agent pi
101
+ send-context export --worker your-project.deno.net --agent pi
88
102
  ```
89
103
 
90
- You'll be guided through picking the agent, writing the brief, curating the appendix, and setting a password. The command prints a link:
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
- node dist/index.js receive 'send-context://…/<id>#<password>' -- pi "continue"
101
- node dist/index.js receive 'send-context://…/<id>#<password>' -- claude "continue"
102
- node dist/index.js receive 'send-context://…/<id>#<password>' -- opencode run "continue"
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
- node dist/index.js receive 'send-context://…/<id>#<password>'
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
@@ -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
- const sections = await promptSections();
75
- if (sections === null)
76
- return;
77
- const appendix = await curateAppendix(messages);
78
- if (appendix === null)
79
- return;
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 p.password({
88
- message: "Set a password (the receiver needs it to decrypt):",
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("0.1.0")
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.0",
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
  }