@polylogicai/polycode 1.1.2 → 1.1.4

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
@@ -1,27 +1,45 @@
1
1
  # polycode
2
2
 
3
- An agentic coding CLI. Runs on your machine with your keys. Every turn is appended to a SHA-256 chained session log on disk, so your history is auditable, replayable, and portable across machines.
3
+ An agentic coding CLI. Runs on your machine, writes a SHA-256 chained session log to disk, and ships with a free hosted tier so you can start in one command. Your history is auditable, replayable, and portable across machines.
4
4
 
5
- ## Install
5
+ ## Install and run
6
+
7
+ One command, no setup, any platform:
6
8
 
7
9
  ```bash
8
- npm install -g @polylogicai/polycode
10
+ npx @polylogicai/polycode@latest
9
11
  ```
10
12
 
11
- ## Quick start
13
+ That is the entire install. The first run provisions a per-install ID under `~/.polycode/` and opens an interactive session. On the free hosted tier you get 60 turns per hour for free, routed through `polylogicai.com/api/polycode/inference`. For unlimited use, set `GROQ_API_KEY` (see below) and polycode will talk to Groq directly and skip the proxy.
14
+
15
+ For repeated use, install globally:
12
16
 
13
17
  ```bash
14
- export GROQ_API_KEY=gsk_...
18
+ npm install -g @polylogicai/polycode
15
19
  polycode
16
20
  ```
17
21
 
18
- That opens an interactive session. For one-shot mode, pass your prompt as an argument:
22
+ ## One-shot mode
23
+
24
+ Pass your prompt as an argument:
19
25
 
20
26
  ```bash
21
- polycode "read README.md and summarize it in one sentence"
27
+ npx @polylogicai/polycode@latest "read README.md and summarize it in one sentence"
22
28
  ```
23
29
 
24
- A free Groq API key is available at `console.groq.com`.
30
+ ## Unlimited use with your own key
31
+
32
+ Grab a free key from `console.groq.com`, then:
33
+
34
+ ```bash
35
+ # macOS and Linux
36
+ export GROQ_API_KEY=gsk_...
37
+ npx @polylogicai/polycode@latest
38
+
39
+ # Windows PowerShell
40
+ $env:GROQ_API_KEY = "gsk_..."
41
+ npx @polylogicai/polycode@latest
42
+ ```
25
43
 
26
44
  ## Configuration
27
45
 
@@ -29,8 +47,8 @@ polycode reads configuration from environment variables and optionally from a `~
29
47
 
30
48
  | Variable | Purpose | Required |
31
49
  |---|---|---|
32
- | `GROQ_API_KEY` | Primary inference key for tool-use and reasoning | yes |
33
- | `ANTHROPIC_API_KEY` | Optional high-quality tier for long sessions | no |
50
+ | `GROQ_API_KEY` | Direct Groq access, unlimited. If unset, polycode uses the hosted tier. | no |
51
+ | `ANTHROPIC_API_KEY` | Optional higher-quality compile tier for long sessions | no |
34
52
  | `POLYCODE_MODEL` | Override the default model | no |
35
53
  | `POLYCODE_CWD` | Override the working directory | no |
36
54
 
@@ -92,8 +110,8 @@ Rules live in `~/.polycode/rules.yaml`. You can add your own.
92
110
  ## Requirements
93
111
 
94
112
  - Node.js 20 or newer
95
- - macOS or Linux
96
- - A Groq API key
113
+ - macOS, Linux, or Windows
114
+ - No API key required for the free hosted tier. Set `GROQ_API_KEY` for unlimited use.
97
115
 
98
116
  ## Documentation and support
99
117
 
package/bin/polycode.mjs CHANGED
@@ -161,8 +161,8 @@ ${C.bold}Usage${C.reset}
161
161
  polycode --verify Verify session history integrity
162
162
 
163
163
  ${C.bold}Environment${C.reset}
164
- GROQ_API_KEY Required (free tier at console.groq.com)
165
- ANTHROPIC_API_KEY Optional high-quality tier for long sessions
164
+ GROQ_API_KEY Optional. If unset, polycode uses the free hosted tier (60 turns/hour) via polylogicai.com. Set a free key from console.groq.com for unlimited use.
165
+ ANTHROPIC_API_KEY Optional higher-quality compile tier for long sessions
166
166
  POLYCODE_MODEL Override the default model
167
167
  POLYCODE_CWD Override the working directory
168
168
 
@@ -259,6 +259,9 @@ async function runOneShot(message, opts) {
259
259
  async function runRepl(opts) {
260
260
  const rl = readline.createInterface({ input: stdin, output: stdout });
261
261
  stdout.write(BANNER + '\n');
262
+ if (opts.hostedMode) {
263
+ stdout.write(`${C.dim}hosted tier: 60 turns/hour via polylogicai.com. Set GROQ_API_KEY for unlimited use.${C.reset}\n`);
264
+ }
262
265
  if (opts.projectContextPath) {
263
266
  stdout.write(`${C.dim}project context: ${opts.projectContextPath}${C.reset}\n`);
264
267
  }
@@ -298,11 +301,12 @@ async function main() {
298
301
 
299
302
  const configDir = resolveConfigDir();
300
303
 
301
- const apiKey = env.GROQ_API_KEY;
302
- if (!apiKey) {
303
- stdout.write(`${C.red}polycode error${C.reset}: GROQ_API_KEY is not set.\n`);
304
- exit(1);
305
- }
304
+ // Zero-config mode: if no GROQ_API_KEY is present, run through the Polylogic
305
+ // hosted inference proxy at polylogicai.com/api/polycode/inference. Users
306
+ // who want unlimited access or their own billing can set GROQ_API_KEY to a
307
+ // free key from console.groq.com and the CLI will talk to Groq directly.
308
+ const apiKey = env.GROQ_API_KEY || '';
309
+ const hostedMode = !apiKey;
306
310
 
307
311
  const rules = loadRules();
308
312
  const model = env.POLYCODE_MODEL || 'moonshotai/kimi-k2-instruct';
@@ -356,10 +360,10 @@ async function main() {
356
360
  canon_path: canonFile,
357
361
  }, hookDir);
358
362
 
359
- const loop = new AgenticLoop({ apiKey, model, rules, projectContext });
363
+ const loop = new AgenticLoop({ apiKey, model, rules, projectContext, hostedMode, version: VERSION });
360
364
  const renderer = createRenderer(stdout, { verbose });
361
365
  const state = { lastTurnTokens: 0, lastCompiler: null };
362
- const opts = { loop, canon, cwd, renderer, state, sessionId, hookDir, verbose, projectContextPath };
366
+ const opts = { loop, canon, cwd, renderer, state, sessionId, hookDir, verbose, projectContextPath, hostedMode };
363
367
 
364
368
  const positional = args.filter((a) => !a.startsWith('--') && args[args.indexOf(a) - 1] !== '--packet');
365
369
  if (positional.length > 0) {
package/lib/agentic.mjs CHANGED
@@ -13,11 +13,62 @@ import Groq from 'groq-sdk';
13
13
  import { promises as fs } from 'node:fs';
14
14
  import { exec } from 'node:child_process';
15
15
  import { promisify } from 'node:util';
16
- import { resolve, relative, sep, dirname } from 'node:path';
16
+ import { resolve, relative, sep, dirname, join, basename } from 'node:path';
17
17
  import { compilePacket } from './compiler.mjs';
18
18
  import { mintCommitment } from './commitment.mjs';
19
19
  import { ensureActiveIntent } from './intent.mjs';
20
20
  import { scrubSecrets } from './witness/secret-scrubber.mjs';
21
+ import { createHostedClient } from './polycode-hosted-client.mjs';
22
+
23
+ // Cross-platform directory walker. Pure Node.js, no shell-out. Skips common
24
+ // noise directories (node_modules, .git, etc.) by default. Yields absolute
25
+ // file paths as an async iterator so callers can bail out early.
26
+ export const DEFAULT_SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', 'dist', 'build', '.next', '.nuxt']);
27
+
28
+ export async function* walkFiles(root, skipDirs = DEFAULT_SKIP_DIRS) {
29
+ let entries;
30
+ try {
31
+ entries = await fs.readdir(root, { withFileTypes: true });
32
+ } catch {
33
+ return;
34
+ }
35
+ for (const entry of entries) {
36
+ if (skipDirs.has(entry.name) || entry.name.startsWith('.DS_Store')) continue;
37
+ const fullPath = join(root, entry.name);
38
+ if (entry.isDirectory()) {
39
+ yield* walkFiles(fullPath, skipDirs);
40
+ } else if (entry.isFile()) {
41
+ yield fullPath;
42
+ }
43
+ }
44
+ }
45
+
46
+ // Minimal glob-to-regex converter. Supports ** (any depth), * (any chars
47
+ // except /), ? (one char). Anchors the pattern with ^ and $ so callers get
48
+ // full-string matching.
49
+ export function globToRegex(pattern) {
50
+ const escaped = pattern
51
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
52
+ .replace(/\*\*\//g, '\u0001')
53
+ .replace(/\*\*/g, '\u0001')
54
+ .replace(/\*/g, '[^/]*')
55
+ .replace(/\?/g, '[^/]')
56
+ .replace(/\u0001/g, '(?:.*/)?');
57
+ return new RegExp('^' + escaped + '$');
58
+ }
59
+
60
+ // Normalize a filesystem path to forward slashes so glob matching and output
61
+ // formatting behave the same on every platform.
62
+ export function toPosix(p) {
63
+ return sep === '/' ? p : p.split(sep).join('/');
64
+ }
65
+
66
+ // Detect whether a file buffer is likely binary by looking for a NUL byte in
67
+ // the first kilobyte. Used by the grep tool to skip binary files.
68
+ export function looksBinary(content) {
69
+ const head = content.slice(0, 1024);
70
+ return head.indexOf('\u0000') !== -1;
71
+ }
21
72
 
22
73
  const execAsync = promisify(exec);
23
74
 
@@ -95,7 +146,7 @@ const TOOL_SCHEMAS = [
95
146
  type: 'function',
96
147
  function: {
97
148
  name: 'bash',
98
- description: 'Run a shell command in the working directory. 30 second timeout. Output truncated at 3200 bytes.',
149
+ description: 'Run a shell command in the working directory. Uses the system default shell, so assume POSIX tools on macOS and Linux and cmd.exe on Windows. Prefer read_file, write_file, edit_file, glob, and grep for file operations; reserve this tool for real commands like running tests or git. 30 second timeout. Output truncated at 3200 bytes.',
99
150
  parameters: {
100
151
  type: 'object',
101
152
  properties: { command: { type: 'string' } },
@@ -239,23 +290,57 @@ async function runTool(name, args, cwd) {
239
290
  }
240
291
  }
241
292
  case 'glob': {
242
- const pattern = String(args.pattern ?? '').replace(/'/g, "'\\''");
293
+ const pattern = String(args.pattern ?? '').trim();
294
+ if (!pattern) return '(no pattern)';
243
295
  try {
244
- const { stdout } = await execAsync(`find . -type f -name '${pattern}' 2>/dev/null | head -200`, { cwd });
245
- return truncateStr(stdout || '(no matches)');
296
+ const regex = globToRegex(pattern);
297
+ const matches = [];
298
+ for await (const fullPath of walkFiles(cwd)) {
299
+ const rel = toPosix(relative(cwd, fullPath));
300
+ if (regex.test(rel) || regex.test(basename(fullPath))) {
301
+ matches.push('./' + rel);
302
+ if (matches.length >= 200) break;
303
+ }
304
+ }
305
+ if (matches.length === 0) return '(no matches)';
306
+ return truncateStr(matches.join('\n'));
246
307
  } catch (err) {
247
308
  return `error: ${err.message}`;
248
309
  }
249
310
  }
250
311
  case 'grep': {
251
- const pattern = String(args.pattern ?? '').replace(/'/g, "'\\''");
252
- const glob = String(args.glob ?? '').replace(/'/g, "'\\''");
312
+ const pattern = String(args.pattern ?? '').trim();
313
+ if (!pattern) return '(no pattern)';
314
+ let regex;
253
315
  try {
254
- const cmd = glob
255
- ? `grep -rn --include='${glob}' -E '${pattern}' . 2>/dev/null | head -100`
256
- : `grep -rn -E '${pattern}' . 2>/dev/null | head -100`;
257
- const { stdout } = await execAsync(cmd, { cwd });
258
- return truncateStr(stdout || '(no matches)');
316
+ regex = new RegExp(pattern);
317
+ } catch (err) {
318
+ return `error: invalid regex: ${err.message}`;
319
+ }
320
+ const globPattern = String(args.glob ?? '').trim();
321
+ const globRegex = globPattern ? globToRegex(globPattern) : null;
322
+ const matches = [];
323
+ try {
324
+ outer: for await (const fullPath of walkFiles(cwd)) {
325
+ const rel = toPosix(relative(cwd, fullPath));
326
+ if (globRegex && !(globRegex.test(rel) || globRegex.test(basename(fullPath)))) continue;
327
+ let content;
328
+ try {
329
+ content = await fs.readFile(fullPath, 'utf8');
330
+ } catch {
331
+ continue;
332
+ }
333
+ if (looksBinary(content)) continue;
334
+ const lines = content.split('\n');
335
+ for (let i = 0; i < lines.length; i++) {
336
+ if (regex.test(lines[i])) {
337
+ matches.push(`./${rel}:${i + 1}:${lines[i].slice(0, 200)}`);
338
+ if (matches.length >= 100) break outer;
339
+ }
340
+ }
341
+ }
342
+ if (matches.length === 0) return '(no matches)';
343
+ return truncateStr(matches.join('\n'));
259
344
  } catch (err) {
260
345
  return `error: ${err.message}`;
261
346
  }
@@ -279,17 +364,21 @@ function recoverInlineToolCalls(content) {
279
364
  }
280
365
 
281
366
  export class AgenticLoop {
282
- constructor({ apiKey, model, logger, rules, projectContext } = {}) {
367
+ constructor({ apiKey, model, logger, rules, projectContext, hostedMode, version } = {}) {
283
368
  this.apiKey = apiKey;
284
369
  this.defaultModel = model || DEFAULT_MODEL;
285
370
  this.logger = logger || console;
286
371
  this.rules = rules || {};
287
372
  this.systemPrompt = buildSystemPrompt(projectContext);
373
+ this.hostedMode = Boolean(hostedMode) || !(apiKey || process.env.GROQ_API_KEY);
374
+ this.version = version || 'unknown';
288
375
  }
289
376
 
290
377
  async runTurn({ canon, userMessage, cwd, onEvent, maxIterations }) {
291
378
  const start = Date.now();
292
- const groq = new Groq({ apiKey: this.apiKey || process.env.GROQ_API_KEY });
379
+ const groq = this.hostedMode
380
+ ? createHostedClient({ version: this.version })
381
+ : new Groq({ apiKey: this.apiKey || process.env.GROQ_API_KEY });
293
382
  let model = this.defaultModel;
294
383
  let didFallback = false;
295
384
  const limit = maxIterations || DEFAULT_MAX_ITERATIONS;
package/lib/hooks.mjs CHANGED
@@ -72,11 +72,17 @@ export async function fireHook(eventName, payload, hookDir) {
72
72
  const isShell = hookPath.endsWith('.sh') || !hookPath.includes('.');
73
73
  const isNode = hookPath.endsWith('.mjs') || hookPath.endsWith('.js');
74
74
 
75
+ // On Windows we cannot spawn sh for POSIX shell hooks. Skip with a clear
76
+ // reason so the caller knows the hook did not run. Node hooks are portable.
77
+ if (isShell && process.platform === 'win32') {
78
+ return { action: 'allow', reason: 'shell hooks not supported on Windows; use .mjs' };
79
+ }
80
+
75
81
  return new Promise((resolveHook) => {
76
82
  let child;
77
83
  try {
78
84
  child = isNode
79
- ? spawn('node', [hookPath], { stdio: ['pipe', 'pipe', 'pipe'] })
85
+ ? spawn(process.execPath, [hookPath], { stdio: ['pipe', 'pipe', 'pipe'] })
80
86
  : spawn('sh', [hookPath], { stdio: ['pipe', 'pipe', 'pipe'] });
81
87
  } catch (err) {
82
88
  resolveHook({ action: 'allow', reason: `spawn failed: ${err.message}` });
@@ -0,0 +1,100 @@
1
+ // lib/polycode-hosted-client.mjs
2
+ // Hosted inference client for users who have not provided a GROQ_API_KEY.
3
+ // Posts chat-completions requests to the Polylogic proxy at
4
+ // polylogicai.com/api/polycode/inference, which forwards them to Groq using
5
+ // a server-side key. Rate limited to 60 turns per install per hour. Power
6
+ // users can skip the proxy by setting GROQ_API_KEY, in which case the agent
7
+ // talks to Groq directly.
8
+ //
9
+ // The returned object mimics the subset of the groq-sdk surface that the
10
+ // agentic loop uses (chat.completions.create), so the call sites do not need
11
+ // to know which backend is in play.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ const DEFAULT_PROXY_URL = 'https://polylogicai.com/api/polycode/inference';
19
+ const CONFIG_DIR = join(homedir(), '.polycode');
20
+ const INSTALL_ID_PATH = join(CONFIG_DIR, 'install_id');
21
+
22
+ // Produce a stable per-install identifier. Created on first run, persisted
23
+ // to ~/.polycode/install_id so subsequent runs reuse the same ID for rate
24
+ // limiting. The file contains a bare UUID with no PII.
25
+ export function getOrCreateInstallId() {
26
+ try {
27
+ if (existsSync(INSTALL_ID_PATH)) {
28
+ const existing = readFileSync(INSTALL_ID_PATH, 'utf8').trim();
29
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(existing)) return existing;
30
+ }
31
+ } catch {
32
+ // fall through and regenerate
33
+ }
34
+ if (!existsSync(CONFIG_DIR)) {
35
+ mkdirSync(CONFIG_DIR, { recursive: true });
36
+ }
37
+ const fresh = randomUUID();
38
+ try {
39
+ writeFileSync(INSTALL_ID_PATH, fresh + '\n', { mode: 0o600 });
40
+ } catch {
41
+ // non-fatal; rate limiting will fall back to per-IP
42
+ }
43
+ return fresh;
44
+ }
45
+
46
+ // Minimal Groq-SDK-shaped client that posts every request to the Polylogic
47
+ // proxy. The shape matches what lib/agentic.mjs calls: an object with a
48
+ // `chat.completions.create(payload)` method returning an OpenAI-compatible
49
+ // chat completion object.
50
+ export function createHostedClient({ version, proxyUrl } = {}) {
51
+ const url = proxyUrl || process.env.POLYCODE_PROXY_URL || DEFAULT_PROXY_URL;
52
+ const installId = getOrCreateInstallId();
53
+ const headers = {
54
+ 'Content-Type': 'application/json',
55
+ 'X-Polycode-Install-ID': installId,
56
+ 'X-Polycode-Version': version || 'unknown',
57
+ };
58
+
59
+ async function createCompletion(payload) {
60
+ let res;
61
+ try {
62
+ res = await fetch(url, {
63
+ method: 'POST',
64
+ headers,
65
+ body: JSON.stringify(payload),
66
+ });
67
+ } catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ const wrapped = new Error(
70
+ `polycode hosted inference unreachable at ${url}: ${msg}. Set GROQ_API_KEY to a free key from console.groq.com to bypass the proxy.`
71
+ );
72
+ wrapped.cause = err;
73
+ throw wrapped;
74
+ }
75
+
76
+ const text = await res.text();
77
+ if (!res.ok) {
78
+ let parsed;
79
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
80
+ const serverMsg = parsed?.error || text || `HTTP ${res.status}`;
81
+ throw new Error(`polycode hosted inference error (${res.status}): ${serverMsg}`);
82
+ }
83
+
84
+ try {
85
+ return JSON.parse(text);
86
+ } catch (err) {
87
+ throw new Error(`polycode hosted inference returned non-JSON body: ${text.slice(0, 200)}`);
88
+ }
89
+ }
90
+
91
+ return {
92
+ chat: {
93
+ completions: {
94
+ create: createCompletion,
95
+ },
96
+ },
97
+ _proxyUrl: url,
98
+ _installId: installId,
99
+ };
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polylogicai/polycode",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "An agentic coding CLI. Runs on your machine with your keys. Every turn is appended to a SHA-256 chained session log, so your history is auditable, replayable, and portable.",
5
5
  "type": "module",
6
6
  "main": "bin/polycode.mjs",