mirror of
https://github.com/BillyOutlast/pinloop-cli.git
synced 2026-10-12 02:26:34 +00:00
The pinloop command line, version 0.3.0
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Andrew Allen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Pinloop CLI
|
||||
|
||||
This repository holds the source of the `pinloop` command, and nothing else.
|
||||
It is the same code published to npm, so you can read what the program does on
|
||||
your machine before you install it.
|
||||
|
||||
Pinloop is a job search tool built for your coding agent to run from a terminal.
|
||||
|
||||
It holds a large collection of frequently updated job postings, can hold your
|
||||
resume and any other preferences, and calls AI models to evaluate postings
|
||||
against what it knows about you.
|
||||
|
||||
## Install
|
||||
|
||||
Needs Node 22 or newer.
|
||||
|
||||
```
|
||||
npm install -g pinloop
|
||||
```
|
||||
|
||||
## Start
|
||||
|
||||
```
|
||||
pinloop
|
||||
```
|
||||
|
||||
Run on its own, `pinloop` prints instructions written for a coding agent.
|
||||
`pinloop welcome` gets your coding agent to walk you through a more structured
|
||||
onboarding flow, and `pinloop guide` gives it the full usage instructions.
|
||||
|
||||
## Accounts and payments
|
||||
|
||||
Run `pinloop login` to make an account.
|
||||
|
||||
The commands talk to Pinloop's servers, so everything except the guide needs an
|
||||
account. Login goes through a browser, with no password.
|
||||
|
||||
A free account gets a limited number of judged postings and semantic searches per
|
||||
month, and `pinloop upgrade` opens the page to raise those limits. Keyword
|
||||
search, filtering, saved lists, stored documents, and routines cost nothing.
|
||||
|
||||
## What is here and what is not
|
||||
|
||||
Everything in this repository runs on your own machine. The program stores a
|
||||
login token and your settings under your home directory, and makes HTTP calls to
|
||||
Pinloop's servers for everything else.
|
||||
|
||||
The servers are a separate, closed codebase and are not in this repository. The
|
||||
job postings, the profile you store, the AI judging, the accounts and the
|
||||
payments all live there.
|
||||
|
||||
## Build it yourself
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run build
|
||||
node dist/cli/pinloop.js
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
The license covers this command line program. It is not permission to use the
|
||||
Pinloop name, the servers, or the job postings the servers hold.
|
||||
|
||||
## Links
|
||||
|
||||
- Home: https://pinloop.ai
|
||||
- Privacy: https://pinloop.ai/privacy
|
||||
- Terms: https://pinloop.ai/terms
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pinloop",
|
||||
"version": "0.3.0",
|
||||
"description": "The Pinloop command line: enable your coding agent to run your whole job search.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pinloop": "./dist/cli/pinloop.js"
|
||||
},
|
||||
"homepage": "https://pinloop.ai",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"log-update": "^8.0.0",
|
||||
"cli-spinners": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* The terminal half of signing in through a browser: the small web server
|
||||
* `pinloop login` starts inside its own process, and the web address it prints.
|
||||
*
|
||||
* The feature is specs/feature-browser-sign-in.md, agreed 2026-08-19. In one
|
||||
* paragraph. `pinloop login` starts listening on 127.0.0.1, which is the address
|
||||
* a machine uses to talk to itself and which no other machine can reach. It
|
||||
* listens on whatever port is free at that moment, and it invents a one-time
|
||||
* secret. It then prints the web address of the sign-in page with that port and
|
||||
* that secret written into it, and waits. A person opens that address in a
|
||||
* browser and signs in with Google, with GitHub, or with a 6-digit code Pinloop
|
||||
* emails them. The script inside the page then sends the pass to 127.0.0.1 on
|
||||
* the port the terminal named, carrying the secret the terminal invented, and
|
||||
* this file hands that pass back to the command, which saves it.
|
||||
*
|
||||
* Two things here are not obvious and are the point of the file.
|
||||
*
|
||||
* The secret. Without it, anything at all running on the machine could hand this
|
||||
* terminal a pass, and a sign-in page still open in another window from an
|
||||
* earlier `pinloop login` could hand a pass to the wrong terminal. A delivery
|
||||
* carrying the wrong secret, or none, is refused and the terminal keeps waiting
|
||||
* for the page it actually sent.
|
||||
*
|
||||
* The browser's question. A browser will not let a page at pinloop.ai send
|
||||
* anything to 127.0.0.1 until it has asked the listener whether that is allowed.
|
||||
* That question arrives as an OPTIONS request, before the delivery. If it goes
|
||||
* unanswered the browser drops the delivery without a word, the terminal waits
|
||||
* its whole ten minutes, and nothing anywhere says why. So it is answered.
|
||||
*
|
||||
* This file imports from src/cli and src/shared only, which is the rule
|
||||
* src/cli/package-boundary.test.ts enforces: the published `pinloop` package is
|
||||
* built out of those two folders and nothing else.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { platform } from 'node:process';
|
||||
|
||||
import {
|
||||
GAVE_UP_LINE,
|
||||
LOOPBACK_CALLBACK_PATH,
|
||||
PORT_PARAMETER,
|
||||
SECRET_PARAMETER,
|
||||
SIGN_IN_PAGE_PATH,
|
||||
SIGN_IN_SITE_URL,
|
||||
SIGN_IN_SITE_URL_ENV_VAR,
|
||||
WAIT_FOR_SIGN_IN_MS,
|
||||
type DeliveredPass,
|
||||
} from '../shared/sign-in.ts';
|
||||
|
||||
/** A terminal that is listening for a browser to hand it a pass. */
|
||||
export type WaitingForSignIn = {
|
||||
/** The whole web address to print: the sign-in page, the port and the secret. */
|
||||
pageUrl: string;
|
||||
/** The port the small web server is listening on, on 127.0.0.1. */
|
||||
port: number;
|
||||
/** The one-time secret this terminal invented. */
|
||||
secret: string;
|
||||
/**
|
||||
* The pass the browser delivered. It fails, with GAVE_UP_LINE as its message,
|
||||
* when nothing was delivered inside the wait.
|
||||
*/
|
||||
pass: Promise<DeliveredPass>;
|
||||
/** Stops listening and drops the clock. Doing it twice is safe. */
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
export type WaitOptions = {
|
||||
/**
|
||||
* Where the sign-in page lives, without a trailing slash. Absent means the
|
||||
* environment variable named in src/shared/sign-in.ts, and the hosted site
|
||||
* when that is unset too.
|
||||
*/
|
||||
pageBaseUrl?: string;
|
||||
/** How long to wait before giving up. Absent means WAIT_FOR_SIGN_IN_MS. */
|
||||
giveUpAfterMs?: number;
|
||||
};
|
||||
|
||||
/** Where the sign-in page lives for this run. */
|
||||
export function signInPageBaseUrl(): string {
|
||||
const chosen = process.env[SIGN_IN_SITE_URL_ENV_VAR];
|
||||
const url = chosen && chosen.trim() !== '' ? chosen.trim() : SIGN_IN_SITE_URL;
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a secret is: 32 characters of hexadecimal, which is 16 bytes of
|
||||
* randomness out of the operating system's own source of it. Guessing one inside
|
||||
* the ten minutes a terminal waits is not a thing that happens.
|
||||
*/
|
||||
const SECRET_BYTES = 16;
|
||||
|
||||
/** The largest delivery this listener will read, in bytes. */
|
||||
const LARGEST_DELIVERY = 16 * 1024;
|
||||
|
||||
/** Whether two strings are the same, without taking longer for a closer guess. */
|
||||
function sameSecret(mine: string, theirs: unknown): boolean {
|
||||
if (typeof theirs !== 'string') return false;
|
||||
const a = Buffer.from(mine, 'utf8');
|
||||
const b = Buffer.from(theirs, 'utf8');
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Reads a request body, keeping no more than LARGEST_DELIVERY of it. */
|
||||
function readBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((done) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let kept = 0;
|
||||
request.on('data', (chunk: Buffer) => {
|
||||
if (kept > LARGEST_DELIVERY) return;
|
||||
kept += chunk.length;
|
||||
chunks.push(Buffer.from(chunk));
|
||||
});
|
||||
request.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
|
||||
request.on('error', () => done(''));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The headers that tell the browser a page from somewhere else may talk to this
|
||||
* listener.
|
||||
*
|
||||
* They say "any page", which sounds broader than it is: this listener answers
|
||||
* only 127.0.0.1, it takes nothing but a delivery carrying the one-time secret
|
||||
* it invented a moment ago, it hands nothing back but the word ok, and it stops
|
||||
* listening the instant one arrives. Naming one site instead would mean this
|
||||
* file having to know which site the person was sent to, and getting that wrong
|
||||
* shows up as a sign-in that hangs with no message anywhere.
|
||||
*/
|
||||
function browserHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'content-type',
|
||||
'Access-Control-Max-Age': '600',
|
||||
};
|
||||
}
|
||||
|
||||
function answer(reply: ServerResponse, status: number, body: Record<string, unknown>): void {
|
||||
reply.writeHead(status, { 'content-type': 'application/json', ...browserHeaders() });
|
||||
reply.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening on 127.0.0.1 and hands back the address to print, the port,
|
||||
* the secret, and a promise of the pass.
|
||||
*
|
||||
* Nothing here opens a browser. Opening one is `openInBrowser` below, which the
|
||||
* command calls separately, because the address has to be printed whether or not
|
||||
* this machine has a browser at all: the person running the command is often a
|
||||
* coding agent, and the human does the browser part on their own screen.
|
||||
*/
|
||||
export async function waitForBrowserSignIn(
|
||||
options: WaitOptions = {},
|
||||
): Promise<WaitingForSignIn> {
|
||||
const secret = randomBytes(SECRET_BYTES).toString('hex');
|
||||
const giveUpAfterMs = options.giveUpAfterMs ?? WAIT_FOR_SIGN_IN_MS;
|
||||
const pageBaseUrl = (options.pageBaseUrl ?? signInPageBaseUrl()).replace(/\/+$/, '');
|
||||
|
||||
let handOver: (pass: DeliveredPass) => void = () => undefined;
|
||||
let giveUpWith: (why: Error) => void = () => undefined;
|
||||
const pass = new Promise<DeliveredPass>((delivered, failed) => {
|
||||
handOver = delivered;
|
||||
giveUpWith = failed;
|
||||
});
|
||||
// Nothing else may ever see this rejection as unhandled: the command attaches
|
||||
// to it straight away, but a caller that stops early has not.
|
||||
pass.catch(() => undefined);
|
||||
|
||||
const server: Server = createServer((request: IncomingMessage, reply: ServerResponse) => {
|
||||
const path = (request.url ?? '/').split('?')[0];
|
||||
|
||||
// The browser's question, asked before it will send the delivery at all.
|
||||
if (request.method === 'OPTIONS') {
|
||||
reply.writeHead(204, browserHeaders());
|
||||
reply.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'POST' || path !== LOOPBACK_CALLBACK_PATH) {
|
||||
answer(reply, 404, { error: 'this is the terminal waiting for a Pinloop sign-in' });
|
||||
return;
|
||||
}
|
||||
|
||||
void readBody(request).then((text) => {
|
||||
let delivered: Partial<DeliveredPass> = {};
|
||||
try {
|
||||
delivered = JSON.parse(text) as Partial<DeliveredPass>;
|
||||
} catch {
|
||||
answer(reply, 400, { error: 'that was not a sign-in' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sameSecret(secret, delivered.secret)) {
|
||||
// Refused, and the wait goes on. This terminal is still waiting for the
|
||||
// page it sent somebody to, and something else knocked.
|
||||
answer(reply, 403, { error: 'this terminal did not send that sign-in' });
|
||||
return;
|
||||
}
|
||||
if (typeof delivered.access_token !== 'string' || delivered.access_token === '') {
|
||||
answer(reply, 400, { error: 'that sign-in carried no pass' });
|
||||
return;
|
||||
}
|
||||
|
||||
answer(reply, 200, { ok: true });
|
||||
handOver({
|
||||
secret,
|
||||
access_token: delivered.access_token,
|
||||
...(typeof delivered.refresh_token === 'string'
|
||||
? { refresh_token: delivered.refresh_token }
|
||||
: {}),
|
||||
...(typeof delivered.email === 'string' ? { email: delivered.email } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((listening, failed) => {
|
||||
server.once('error', failed);
|
||||
server.listen(0, '127.0.0.1', () => listening());
|
||||
});
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
|
||||
const clock = setTimeout(() => giveUpWith(new Error(GAVE_UP_LINE)), giveUpAfterMs);
|
||||
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearTimeout(clock);
|
||||
await new Promise<void>((closed) => server.close(() => closed()));
|
||||
};
|
||||
|
||||
const pageUrl =
|
||||
`${pageBaseUrl}${SIGN_IN_PAGE_PATH}` +
|
||||
`?${PORT_PARAMETER}=${port}&${SECRET_PARAMETER}=${secret}`;
|
||||
|
||||
return { pageUrl, port, secret, pass, stop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens one web address in this machine's browser, if this machine has one.
|
||||
*
|
||||
* Best effort and never required. It is started and forgotten: nothing waits for
|
||||
* it, nothing reads what it printed, and a machine with no browser at all — a
|
||||
* rented one reached over SSH, which is where the short code exists for — simply
|
||||
* has nothing happen. The address is always printed as well, so the person can
|
||||
* open it themselves wherever their browser actually is.
|
||||
*/
|
||||
export function openInBrowser(url: string): void {
|
||||
const opener = browserOpener();
|
||||
if (opener === undefined) return;
|
||||
try {
|
||||
const started = spawn(opener.command, [...opener.before, url], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
// A machine where the opener is not installed raises an error on the object
|
||||
// rather than throwing, and an unheard error event on a child process ends
|
||||
// the whole program.
|
||||
started.on('error', () => undefined);
|
||||
started.unref();
|
||||
} catch {
|
||||
// Whatever went wrong, the address is printed and the person can open it.
|
||||
}
|
||||
}
|
||||
|
||||
/** The program that opens a web address on this machine, if there is one. */
|
||||
function browserOpener(): { command: string; before: string[] } | undefined {
|
||||
if (platform === 'darwin') return { command: 'open', before: [] };
|
||||
if (platform === 'win32') return { command: 'cmd', before: ['/c', 'start', ''] };
|
||||
// On Linux, a machine with no screen attached has no browser to open a page
|
||||
// in, and every terminal reached over SSH is such a machine. Asking whether a
|
||||
// screen exists is what keeps this from starting a program that cannot
|
||||
// possibly do anything on exactly the machines the short code exists for.
|
||||
const hasAScreen =
|
||||
(process.env['DISPLAY'] ?? '') !== '' || (process.env['WAYLAND_DISPLAY'] ?? '') !== '';
|
||||
if (!hasAScreen) return undefined;
|
||||
return { command: 'xdg-open', before: [] };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Turning what the server sent into the words a person reads at a terminal.
|
||||
*
|
||||
* Only the command line prints for people. The server hands back whole numbers
|
||||
* and stored fields, and the command line writes them as a sentence, so these
|
||||
* functions live beside the command line rather than beside the counting. The
|
||||
* counting itself, and the shape the two numbers travel in, is
|
||||
* src/shared/coverage.ts, and the rule about always leading with the fraction is
|
||||
* written out in full there.
|
||||
*
|
||||
* Nothing in this file reads a file, opens a connection or calls anything. It
|
||||
* takes numbers and text and gives back text.
|
||||
*/
|
||||
import type { Coverage } from '../shared/coverage.ts';
|
||||
|
||||
/** One whole number with thousands separators, for a person to read. */
|
||||
export function withSeparators(count: number): string {
|
||||
return count.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/**
|
||||
* The fraction a person reads, real numbers and never reduced: "996/1,000".
|
||||
* A whole of nothing reads "0/0", because there was nothing to cover.
|
||||
*/
|
||||
export function fractionOf(covered: number, total: number): string {
|
||||
return `${withSeparators(covered)}/${withSeparators(total)}`;
|
||||
}
|
||||
|
||||
/** The same fraction, taken straight off a coverage the answer carried. */
|
||||
export function fractionIn(held: Coverage): string {
|
||||
return fractionOf(held.covered, held.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* One posting named the way a person recognises it: the job title, then the
|
||||
* employer, then the posting id in parentheses.
|
||||
*
|
||||
* A message that named a posting by its id alone — "could not judge
|
||||
* jb-88213" — left the person reading it with a string they never typed and
|
||||
* cannot place, so they had to go and fetch the posting to find out which job
|
||||
* the message was even about. The title and the employer are what answer that,
|
||||
* and the id stays on the end because it is the thing that gets pasted into
|
||||
* `pinloop fetch` or `pinloop judgment get` afterwards.
|
||||
*
|
||||
* A posting the server sent no title for is named by its employer alone, and one
|
||||
* with neither a title nor an employer falls back to the bare id, because "the
|
||||
* posting at " with nothing after it is worse than the id it replaced.
|
||||
*/
|
||||
export function postingNamed(
|
||||
title: string | null | undefined,
|
||||
company: string | null | undefined,
|
||||
id: string,
|
||||
): string {
|
||||
const job = String(title ?? '').trim();
|
||||
const employer = String(company ?? '').trim();
|
||||
if (job !== '' && employer !== '') return `the ${job} posting at ${employer} (${id})`;
|
||||
if (job !== '') return `the ${job} posting (${id})`;
|
||||
if (employer !== '') return `the posting at ${employer} (${id})`;
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* STUB. The CLI does not exist yet.
|
||||
*
|
||||
* The names below are not implementation — they are the contract the CLI tests
|
||||
* pin: which environment variables point the CLI at a server and at a config
|
||||
* directory, and what the saved-login file is called inside that directory.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment variable holding the base URL of the Pinloop server the CLI talks
|
||||
* to. The tests set it to the locally started server.
|
||||
*/
|
||||
export const SERVER_URL_ENV_VAR = 'PINLOOP_SERVER_URL';
|
||||
|
||||
/**
|
||||
* The hosted server every installed copy of this command talks to when the
|
||||
* environment variable above is not set. This is compiled into every published
|
||||
* copy, so a copy already installed on somebody's machine keeps using whatever
|
||||
* address was in it at install time. Changing it therefore strands every
|
||||
* installed copy unless the old address keeps answering, which is why the value
|
||||
* is pinned by a test rather than left as a string anyone can edit quietly.
|
||||
*/
|
||||
export const DEFAULT_SERVER_URL = 'https://api.pinloop.ai';
|
||||
|
||||
/**
|
||||
* Environment variable overriding where the CLI keeps its config. Without it the
|
||||
* CLI uses the usual per-user location; the tests point it at a throwaway
|
||||
* directory so a test run never touches Andrew's real login.
|
||||
*/
|
||||
export const CONFIG_DIR_ENV_VAR = 'PINLOOP_CONFIG_DIR';
|
||||
|
||||
/**
|
||||
* The file inside the config directory that holds the signed pass after a
|
||||
* successful login. Owner-read/write only, like an ssh private key.
|
||||
*/
|
||||
export const CREDENTIALS_FILENAME = 'credentials.json';
|
||||
|
||||
/** The file mode the saved-login file must carry: owner read+write, nothing else. */
|
||||
export const CREDENTIALS_FILE_MODE = 0o600;
|
||||
+4728
File diff suppressed because it is too large
Load Diff
+186
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* One row of a list, printed the way a person reads it
|
||||
* (specs/feature-terminal-polish.md, AGREED 2026-08-19, the section headed
|
||||
* "Aligned rows with colour when a command answers").
|
||||
*
|
||||
* Every function here takes a stored row, the colour functions to build it with,
|
||||
* and how wide the window is, and hands back the text of that row. None of them
|
||||
* writes anything anywhere and none of them reaches for a colour of its own.
|
||||
* That is what makes the same code produce coloured rows for a person and plain
|
||||
* rows for a pipe: the caller hands in colour functions that colour nothing when
|
||||
* nobody is watching a screen.
|
||||
*
|
||||
* The shape one posting prints as:
|
||||
*
|
||||
* Software Engineer Intern Joby Aviation Santa Cruz, CA 2026-08-12
|
||||
* 7f732227 https://jobs.jobvite.com/joby/job/abc
|
||||
*
|
||||
* Three spaces between the columns of the first row, which is wide enough to
|
||||
* read as a break and narrow enough that a job title and an employer stay on one
|
||||
* row. The second row is indented by two spaces and carries the posting's id and
|
||||
* its web address, both of which a person copies rather than reads, so they are
|
||||
* out of the way of the reading and still easy to select. The job title is the
|
||||
* one thing in bold; everything else on both rows is dim.
|
||||
*
|
||||
* Nothing here is ever cut short. Only the group of lines that is redrawn in
|
||||
* place is cut (src/cli/screen.ts), because erasing that group works by counting
|
||||
* the rows it drew and a row the terminal wrapped makes the count wrong. A
|
||||
* printed row is written once and never erased, so a terminal wrapping it costs
|
||||
* nothing, and cutting a job title in half would cost a person the thing they
|
||||
* were reading.
|
||||
*/
|
||||
import type { Colours } from './screen.ts';
|
||||
|
||||
/** How many characters sit between two columns of the same row. */
|
||||
const BETWEEN_COLUMNS = ' ';
|
||||
|
||||
/** How far the second row of a posting is indented. */
|
||||
const INDENT = ' ';
|
||||
|
||||
/**
|
||||
* How many characters sit between the id and the web address on that second
|
||||
* row. Two rather than three, because both are things a person double-clicks
|
||||
* rather than reads, and they read as one pair.
|
||||
*/
|
||||
const BETWEEN_ID_AND_LINK = ' ';
|
||||
|
||||
/** How much of an id is printed where the whole thing is not needed. */
|
||||
const SHORT_ID = 8;
|
||||
|
||||
/** One posting, as a search, a list, a tab and a routine run all print it. */
|
||||
export type PostingRow = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
company?: string | null;
|
||||
locations?: string[] | null;
|
||||
posted_at?: string | null;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
/** One employer, as `pinloop companies` prints it. */
|
||||
export type CompanyRow = {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
website_domain?: string | null;
|
||||
posting_count?: number | null;
|
||||
};
|
||||
|
||||
/** One tab, as `pinloop tab list` prints it. */
|
||||
export type TabListRow = {
|
||||
name?: string | null;
|
||||
items?: number | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
/** One stored verdict, as judge and `pinloop judgment list` print it. */
|
||||
export type VerdictRow = {
|
||||
id?: string | null;
|
||||
verdict?: string | null;
|
||||
judged_at?: string | null;
|
||||
judged_by?: string | null;
|
||||
};
|
||||
|
||||
/** The parts of a row that carried something, joined into one row. */
|
||||
function joined(parts: (string | undefined)[]): string {
|
||||
return parts.filter((one): one is string => one !== undefined && one !== '').join(BETWEEN_COLUMNS);
|
||||
}
|
||||
|
||||
/** One value as text, or nothing at all when the row carries nothing for it. */
|
||||
function said(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) return undefined;
|
||||
const text = String(value);
|
||||
return text === '' ? undefined : text;
|
||||
}
|
||||
|
||||
/** The day a date fell on, which is all of a stored instant a person reads. */
|
||||
function day(value: unknown): string | undefined {
|
||||
const text = said(value);
|
||||
return text === undefined ? undefined : text.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* One posting as two rows: what it is, and then how to reach it.
|
||||
*
|
||||
* A column the posting carries nothing for is left out rather than printed
|
||||
* empty, so a posting with no location and no date reads as a title and an
|
||||
* employer rather than as a row of gaps.
|
||||
*/
|
||||
export function postingRow(row: PostingRow, colours: Colours, _columns: number): string {
|
||||
const facts = joined([said(row.company), said(row.locations?.[0]), day(row.posted_at)]);
|
||||
const first = joined([colours.bold(said(row.title) ?? ''), facts === '' ? undefined : colours.dim(facts)]);
|
||||
const reach = [said(row.id)?.slice(0, SHORT_ID), said(row.url)]
|
||||
.filter((one): one is string => one !== undefined && one !== '')
|
||||
.join(BETWEEN_ID_AND_LINK);
|
||||
return `${first}\n${INDENT}${colours.dim(reach)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One employer as two rows, with its whole id on the second.
|
||||
*
|
||||
* The whole id and not the first eight characters, because handing that id to
|
||||
* `pinloop search --company` is the only reason this list exists. Employer names
|
||||
* in the corpus are messy — "Google" and "Google LLC" are two separate rows —
|
||||
* so a person picks the right employer by its name and its posting count and
|
||||
* then copies the id underneath it.
|
||||
*/
|
||||
export function companyRow(row: CompanyRow, colours: Colours, _columns: number): string {
|
||||
const count = Number(row.posting_count ?? 0);
|
||||
const facts = joined([
|
||||
said(row.website_domain),
|
||||
`${count} posting${count === 1 ? '' : 's'}`,
|
||||
]);
|
||||
const first = joined([colours.bold(said(row.name) ?? ''), colours.dim(facts)]);
|
||||
return `${first}\n${INDENT}${colours.dim(said(row.id) ?? '')}`;
|
||||
}
|
||||
|
||||
/** One tab as a single row: its name, how much it holds, and what it is for. */
|
||||
export function tabRow(row: TabListRow, colours: Colours, _columns: number): string {
|
||||
const items = Number(row.items ?? 0);
|
||||
const facts = joined([
|
||||
`${items} posting${items === 1 ? '' : 's'}`,
|
||||
said(row.description),
|
||||
]);
|
||||
return joined([colours.bold(said(row.name) ?? ''), colours.dim(facts)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour each of the four verdict words is written in, worst to best: red
|
||||
* for a no, dim for a weak, yellow for a fair, green for a strong. A word
|
||||
* outside the four is left uncoloured rather than guessed at.
|
||||
*/
|
||||
function inItsOwnColour(verdict: string, colours: Colours): string {
|
||||
if (verdict === 'no') return colours.red(verdict);
|
||||
if (verdict === 'weak') return colours.dim(verdict);
|
||||
if (verdict === 'fair') return colours.yellow(verdict);
|
||||
if (verdict === 'strong') return colours.green(verdict);
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stored verdict as a single row: the word, then the posting it is about,
|
||||
* when it was made and who made it.
|
||||
*
|
||||
* Who judged is on every row because a verdict Pinloop bought and one a person
|
||||
* stored by hand are otherwise identical to look at, and telling them apart at a
|
||||
* glance is the whole point of the column.
|
||||
*/
|
||||
export function verdictRow(row: VerdictRow, colours: Colours, _columns: number): string {
|
||||
const word = said(row.verdict) ?? '';
|
||||
const rest = joined([
|
||||
said(row.id),
|
||||
day(row.judged_at),
|
||||
said(row.judged_by) === undefined ? undefined : `judged by ${said(row.judged_by)}`,
|
||||
]);
|
||||
return `${inItsOwnColour(word, colours)}${BETWEEN_COLUMNS}${colours.dim(rest)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line a list ends with, saying how much of what matched came back.
|
||||
*
|
||||
* The words are the caller's and are not touched here. All this does is dim
|
||||
* them, so that the line reads as a note about the list rather than as another
|
||||
* row of it.
|
||||
*/
|
||||
export function summaryLine(line: string, colours: Colours): string {
|
||||
return colours.dim(line);
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
/**
|
||||
* The one piece of code inside the `pinloop` command that writes to the screen
|
||||
* while a command is still working (specs/feature-terminal-polish.md, AGREED
|
||||
* 2026-08-19).
|
||||
*
|
||||
* Everything a command says WHILE it works goes through here, and every one of
|
||||
* those writes goes to standard error. Standard output is left alone entirely,
|
||||
* whoever is watching, because standard output is what a shell pipe reads and
|
||||
* what the next command in a pipeline parses. The answer itself is printed by
|
||||
* the command's own code, after the work has finished, exactly as it was printed
|
||||
* before this file existed.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Two ways of printing, decided once
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* As the command starts it asks the operating system one question: is standard
|
||||
* error attached to a terminal window? A terminal window means a person is
|
||||
* reading, and the command prints for a person. A pipe, a file or a coding
|
||||
* agent's captured output means a program is reading, and the command prints for
|
||||
* a coding agent. Typing `--plain` forces the second way inside a terminal
|
||||
* window.
|
||||
*
|
||||
* Printing for a person means a small group of lines at the bottom of the
|
||||
* screen that is erased and drawn again in place, so nothing scrolls past:
|
||||
*
|
||||
* judging 12 postings, 4 at a time, anthropic/claude-sonnet-5
|
||||
* o 7f732227 thinking: comparing the Paris role against the location
|
||||
* o a91c0d3e answer 1,240 chars
|
||||
* v 5 of 12 done x 0 failed 0:48
|
||||
*
|
||||
* A quick screen, which is `pinloop judge --quick`, gets that same group of
|
||||
* lines with the count of finished postings left off the last one, so the last
|
||||
* line reads `x 0 failed 0:48`. One quick call carries up to a hundred
|
||||
* postings, so that count cannot move until the call comes back (Andrew,
|
||||
* 2026-08-20).
|
||||
*
|
||||
* and, for every other command, one line carrying the same turning character,
|
||||
* what it is doing and a clock:
|
||||
*
|
||||
* o searching... 0:04
|
||||
*
|
||||
* A command that has to say more than fits after the clock draws a second row
|
||||
* underneath the waiting line, indented by two spaces, with no turning
|
||||
* character on it:
|
||||
*
|
||||
* o listing... page 27, 2700 postings so far
|
||||
* over the per-minute limit, asking for the same page again in 5s
|
||||
*
|
||||
* The first row keeps what the run has got to, and the second row carries the
|
||||
* explanation. A `pinloop list --all` run that goes over the account's
|
||||
* allowance used to put the whole explanation after the clock, where it was cut
|
||||
* off part way through a word and where it took the page count off the screen,
|
||||
* so the one thing showing that the run was still moving disappeared (Andrew,
|
||||
* 2026-08-19).
|
||||
*
|
||||
* A second row that names a number of seconds still to wait counts that number
|
||||
* down, one a second, until the wait is over: 5s, then 4s, then 3s, then 2s,
|
||||
* then 1s. It never shows 0s and never shows a fraction. Andrew watched a run
|
||||
* at his own terminal on 2026-08-20 where the row said "in 5s", five seconds
|
||||
* went by, and the row still said "in 5s", so there was no way to tell a
|
||||
* command that was about to ask again from one that had stopped for good.
|
||||
*
|
||||
* The letter o at the front of a running call's row and at the front of the
|
||||
* waiting line stands in for a character that turns. The real characters are the
|
||||
* ten braille dot patterns of the `cli-spinners` package's `dots` animation, and
|
||||
* a new one of them is shown every 80 milliseconds, so a call the model has gone
|
||||
* quiet on and a command that is only waiting both visibly move (Andrew,
|
||||
* 2026-08-19, after watching a run where nothing on the screen moved at all).
|
||||
* The letters v and x stand in for the tick and the cross on the row counting
|
||||
* what is done. All three are written as letters here only so that this comment
|
||||
* stays plain text; the code below uses the real characters.
|
||||
*
|
||||
* Printing for a coding agent means a short list of plain lines and nothing
|
||||
* else. Nothing is ever erased, because a pipe and a file only accept more text,
|
||||
* and none of the model's thinking is written at all: a coding agent cannot do
|
||||
* anything with the thinking and has to store every line it is handed.
|
||||
*
|
||||
* judging 12 postings with anthropic/claude-sonnet-5 via Anthropic
|
||||
* verdict for 7f732227: no (5 of 12 done)
|
||||
* call 1 of 3 done, 100 verdicts (100 of 300 done)
|
||||
* call 2 of 3 failed: the model provider closed the connection
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* Why a row is cut rather than wrapped
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* The group of lines is erased by counting the rows it drew and moving the
|
||||
* cursor back up that many rows. A row longer than the window is carried onto a
|
||||
* second screen row by the terminal itself, which makes that count wrong and
|
||||
* leaves half of the old group on the screen. So every row is cut to fit and
|
||||
* ended with the single ellipsis character. Rows are cut one character short of
|
||||
* the window's width, because a terminal that has just had its very last column
|
||||
* filled is already sitting on the row below.
|
||||
*
|
||||
* The row showing a model's thinking is the one row cut the other way round. The
|
||||
* provider sends the thinking a few characters at a time; this file joins those
|
||||
* pieces together and the row shows the END of the joined text, with the
|
||||
* ellipsis character at the front where the earlier words were left out. The
|
||||
* words then slide leftwards off the row as new ones arrive, so the row always
|
||||
* reads as a run of sentences rather than as whichever fragment arrived last
|
||||
* (Andrew, 2026-08-19, after watching a real run).
|
||||
*/
|
||||
import spinners from 'cli-spinners';
|
||||
import { createLogUpdate } from 'log-update';
|
||||
import { createColors } from 'picocolors';
|
||||
|
||||
import { withSeparators } from './format.ts';
|
||||
import { MAX_IN_FLIGHT_REQUESTS } from '../shared/model-calls.ts';
|
||||
import type { ProgressEvent } from '../shared/progress-events.ts';
|
||||
|
||||
/**
|
||||
* The functions that wrap a piece of text in the codes that colour it.
|
||||
*
|
||||
* They are handed to everything that builds a row rather than reached for, so
|
||||
* that the same row-building code produces coloured text for a person and
|
||||
* untouched text for a pipe, and so that a test can hand in functions that mark
|
||||
* their text with words instead of with invisible codes.
|
||||
*/
|
||||
export type Colours = {
|
||||
bold: (text: string) => string;
|
||||
dim: (text: string) => string;
|
||||
red: (text: string) => string;
|
||||
yellow: (text: string) => string;
|
||||
green: (text: string) => string;
|
||||
};
|
||||
|
||||
/** Which of the two ways of printing this run picked. */
|
||||
export type ScreenMode = 'person' | 'program';
|
||||
|
||||
/** What one screen is told about the terminal it is writing to. */
|
||||
export type ScreenOptions = {
|
||||
/** Whether standard error is attached to a terminal window. */
|
||||
stderrIsTTY: boolean;
|
||||
/** Whether standard output is attached to a terminal window. */
|
||||
stdoutIsTTY: boolean;
|
||||
/** Whether the person typed --plain. */
|
||||
plain: boolean;
|
||||
/** Whether the environment variable NO_COLOR is set to anything. */
|
||||
noColor: boolean;
|
||||
/** How many characters wide the terminal window is. */
|
||||
columns: number;
|
||||
/** The stream every write goes to. */
|
||||
stderr: NodeJS.WritableStream;
|
||||
/** Standard output, which this file never writes a byte to. */
|
||||
stdout: NodeJS.WritableStream;
|
||||
/** The clock, so that a test can move time by hand. */
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
/** The screen a command draws on while it works. */
|
||||
export type Screen = {
|
||||
/** Which of the two ways of printing this run picked. */
|
||||
readonly mode: ScreenMode;
|
||||
/** The colour functions every printed row is built with. */
|
||||
readonly colours: Colours;
|
||||
/**
|
||||
* This command is now waiting on the server, doing the thing this label
|
||||
* names. Calling it again while a wait is already going changes the label
|
||||
* without restarting the clock, which is how a routine run says which step it
|
||||
* has reached. Waits nest: two calls need two stops.
|
||||
*/
|
||||
wait(label: string): void;
|
||||
/**
|
||||
* The same wait, doing something else now. It changes the words on the line
|
||||
* without opening a second wait and without restarting the clock, which is how
|
||||
* a routine run says which of its steps it has reached.
|
||||
*/
|
||||
relabel(label: string): void;
|
||||
/** What the waiting command has got to, in place of the clock. */
|
||||
update(detail: string): void;
|
||||
/**
|
||||
* A second row of text drawn underneath the waiting line, for an explanation
|
||||
* too long to sit after the clock. The row is indented by two spaces and
|
||||
* carries no turning character, and calling this with nothing takes the row
|
||||
* off again. What the waiting command has got to stays on the first row
|
||||
* either way, so the explanation never hides it.
|
||||
*/
|
||||
note(saying: string | undefined): void;
|
||||
/**
|
||||
* The same second row, carrying a number of seconds that falls as a wait runs
|
||||
* out.
|
||||
*
|
||||
* `milliseconds` is how long the wait has left to run from this moment, and
|
||||
* `wording` is asked for the sentence to draw every time the row is redrawn,
|
||||
* with the whole seconds still to wait handed to it. The number is rounded
|
||||
* up and never goes below 1, so the row never reads "in 0s" and never reads a
|
||||
* fraction of a second. The words themselves stay with the command that is
|
||||
* waiting; all this does is redraw them as the number changes.
|
||||
*
|
||||
* The row comes off the same way any other second row does: `note` with a
|
||||
* plain sentence replaces it, and `note` with nothing takes it away.
|
||||
*/
|
||||
countdown(milliseconds: number, wording: (secondsLeft: number) => string): void;
|
||||
/** This wait is over. */
|
||||
stop(): void;
|
||||
/**
|
||||
* Whether the judge run that is about to start is a quick screen, which is
|
||||
* `pinloop judge --quick`: up to a hundred postings sent in one model call
|
||||
* rather than one call for each posting.
|
||||
*
|
||||
* The judge command says which kind of run it is before the run starts.
|
||||
* Nothing here works it out from the messages the server sends, because the
|
||||
* messages of a quick screen carrying a hundred postings and the messages of
|
||||
* an ordinary run of a hundred postings say the same things.
|
||||
*
|
||||
* The one thing it changes is the last row of the group drawn while the run
|
||||
* goes. On a quick screen that row leaves out the count of finished postings;
|
||||
* see `judgeRows` further down for why.
|
||||
*/
|
||||
judgeIsQuick(quick: boolean): void;
|
||||
/** One thing that happened while a judge run was going. */
|
||||
judge(event: ProgressEvent): void;
|
||||
/** The whole command is done: erase everything and give the cursor back. */
|
||||
finish(): void;
|
||||
/** Make the cursor visible again, right now. */
|
||||
restoreCursor(): void;
|
||||
};
|
||||
|
||||
/** The bytes that make the cursor visible again. */
|
||||
const SHOW_CURSOR = '\u001b[?25h';
|
||||
|
||||
/** How long a command waits before it draws a waiting line at all. */
|
||||
const QUIET_FIRST_MS = 1_000;
|
||||
|
||||
/** The shortest gap between two redraws, so the screen does not flicker. */
|
||||
const REDRAW_EVERY_MS = 100;
|
||||
|
||||
/**
|
||||
* How tall the screen is taken to be when the terminal will not say. A terminal
|
||||
* that reports no height, or a height of zero, would otherwise have everything
|
||||
* clipped away and nothing drawn.
|
||||
*/
|
||||
const ASSUMED_HEIGHT = 24;
|
||||
|
||||
/** Below this many characters wide, the group shrinks to its last row alone. */
|
||||
const NARROW_WINDOW = 40;
|
||||
|
||||
/**
|
||||
* The characters that turn at the front of the waiting line and at the front of
|
||||
* every row of a call that is still running.
|
||||
*
|
||||
* The `cli-spinners` package is a plain list of animations with no code in it
|
||||
* and no packages of its own. The one taken here is `dots`: ten braille
|
||||
* characters with a different pattern of dots raised in each, shown one after
|
||||
* another 80 milliseconds apart, which reads as a small shape turning on the
|
||||
* spot. Andrew watched the built command run on 2026-08-19 and asked for this:
|
||||
* until then the waiting line was a word and a clock that changed once a second
|
||||
* and stood still the rest of the time, and the row of a call the model had gone
|
||||
* quiet on did not change at all, so a person could not tell a slow run from a
|
||||
* stuck one.
|
||||
*/
|
||||
const TURNING = spinners.dots;
|
||||
|
||||
/**
|
||||
* The character to show at the front of a moving row after this many
|
||||
* milliseconds of waiting or of judging.
|
||||
*
|
||||
* The character is worked out from elapsed time rather than counted up on each
|
||||
* redraw. The redraw happens ten times a second and the animation wants a new
|
||||
* character every 80 milliseconds, so the two do not line up; reading the clock
|
||||
* keeps the animation at its own speed whatever the redraw does, and it means a
|
||||
* test that moves the clock by hand always gets the same character back.
|
||||
*/
|
||||
function turningAt(milliseconds: number): string {
|
||||
const step = Math.floor(Math.max(0, milliseconds) / TURNING.interval);
|
||||
return TURNING.frames[step % TURNING.frames.length] ?? '';
|
||||
}
|
||||
|
||||
/** The marks on the row counting what is done and what could not be done. */
|
||||
const DONE_MARK = '✓';
|
||||
const FAILED_MARK = '✗';
|
||||
|
||||
/**
|
||||
* The one character a row that had to be cut short ends with, and the one that
|
||||
* separates the waiting line's label from its clock. Three full stops in a row
|
||||
* would take three columns of a window this code is already fighting for.
|
||||
*/
|
||||
const CUT_MARK = '…';
|
||||
|
||||
/** What a call's row says before the model has sent anything. */
|
||||
const NOTHING_YET = 'starting';
|
||||
|
||||
/**
|
||||
* How many characters of one call's thinking are kept.
|
||||
*
|
||||
* The provider sends the model's thinking a few characters at a time, and this
|
||||
* file joins those pieces together so the row reads as sentences rather than as
|
||||
* whatever fragment arrived last. A model can think for minutes, so the joined
|
||||
* text would grow to hundreds of thousands of characters if nothing threw any
|
||||
* of it away. Only the end of it is ever shown, and a hundred-column window
|
||||
* shows about eighty characters, so keeping the last few thousand is far more
|
||||
* than any row can hold and still costs almost nothing to hold in memory.
|
||||
*/
|
||||
const THINKING_KEPT = 4_000;
|
||||
|
||||
/**
|
||||
* The model's thinking with every run of spaces, tabs and newlines turned into
|
||||
* one single space, and any space at the very front taken off.
|
||||
*
|
||||
* A model that writes in paragraphs sends newlines, and a newline inside a row
|
||||
* would push the rest of the group onto extra screen rows, which makes the
|
||||
* count of rows to erase wrong and leaves half of the old group behind.
|
||||
*/
|
||||
function oneLine(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').replace(/^ /, '');
|
||||
}
|
||||
|
||||
/** A count of minutes and seconds, as a person reads a stopwatch. */
|
||||
function clockText(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One posting named the way every row here names it: the first eight characters
|
||||
* of its id.
|
||||
*
|
||||
* A server that is older than this command sends no posting on the message that
|
||||
* opens a call, because that field was added with this feature. The row then
|
||||
* names nothing rather than printing the word "undefined" across the screen.
|
||||
*/
|
||||
function shortId(id: string | undefined): string {
|
||||
if (id === undefined || id === null || String(id) === '') return '';
|
||||
return String(id).slice(0, 8);
|
||||
}
|
||||
|
||||
/** "1 posting" or "12 postings". */
|
||||
function postingWord(count: number): string {
|
||||
return `${count} posting${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one open call is doing, kept so its row can be drawn again on the next
|
||||
* redraw without waiting for the model to say something new.
|
||||
*/
|
||||
type OpenCall = {
|
||||
/** The posting this call is about, or the first of the many it carries. */
|
||||
id: string;
|
||||
/** How many postings this one call carries. */
|
||||
postings: number;
|
||||
/** How many calls the whole run makes. */
|
||||
of: number;
|
||||
/** The last thing that happened on this call, already written out as a row. */
|
||||
latest: string;
|
||||
/**
|
||||
* Everything the model has thought on this call so far, with the pieces it
|
||||
* arrived in joined together and every run of whitespace turned into one
|
||||
* space. Empty when the model has sent no thinking, or when an answer has
|
||||
* arrived and taken the row over.
|
||||
*/
|
||||
thinking: string;
|
||||
/** How many verdicts this call has produced so far. */
|
||||
verdicts: number;
|
||||
};
|
||||
|
||||
export function openScreen(options: ScreenOptions): Screen {
|
||||
const mode: ScreenMode = options.stderrIsTTY && !options.plain ? 'person' : 'program';
|
||||
// Colour is decided by standard output rather than by standard error, because
|
||||
// colour goes on the answer and the answer goes to standard output. The two
|
||||
// streams can point at different places: `pinloop search x | less` leaves the
|
||||
// waiting line on the screen and hands `less` plain text.
|
||||
const colours = createColors(
|
||||
options.stdoutIsTTY && !options.noColor && !options.plain,
|
||||
) as unknown as Colours;
|
||||
const now = options.now ?? ((): number => Date.now());
|
||||
const say = (line: string): void => {
|
||||
options.stderr.write(`${line}\n`);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// What a judge run has said so far. Both ways of printing keep the same
|
||||
// record: one entry per call that is still running, plus the counts.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** The calls still running, in the order they started. */
|
||||
const open = new Map<number, OpenCall>();
|
||||
/** How many postings the whole run covers, worked out from the first call. */
|
||||
let runPostings = 0;
|
||||
/** The model that really answered, as the provider named it. */
|
||||
let runModel = '';
|
||||
/** True once the line naming the run has been printed for a coding agent. */
|
||||
let namedTheRun = false;
|
||||
/** Postings finished, and calls that could not be finished. */
|
||||
let done = 0;
|
||||
let failedCalls = 0;
|
||||
/**
|
||||
* True when this run is a quick screen rather than an ordinary judge run, as
|
||||
* the judge command said before the run started.
|
||||
*/
|
||||
let quickRun = false;
|
||||
|
||||
/**
|
||||
* How many postings the whole run covers, read off the first call that
|
||||
* started. An ordinary run makes one call per posting; a screening run makes
|
||||
* one call per hundred, and says so on every call it starts.
|
||||
*/
|
||||
const wholeRun = (event: { of: number; postings: number }): number =>
|
||||
Math.max(0, event.of) * Math.max(1, event.postings);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Printing for a coding agent
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if (mode === 'program') {
|
||||
/** The line one screening call prints once it has produced its verdicts. */
|
||||
const callDone = (call: number, one: OpenCall): void => {
|
||||
say(
|
||||
`call ${call} of ${one.of} done, ${one.verdicts} verdicts ` +
|
||||
`(${done} of ${runPostings} done)`,
|
||||
);
|
||||
one.verdicts = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
colours,
|
||||
wait: () => undefined,
|
||||
relabel: () => undefined,
|
||||
update: () => undefined,
|
||||
note: () => undefined,
|
||||
countdown: () => undefined,
|
||||
stop: () => undefined,
|
||||
// A coding agent's lines are the same lines whichever kind of run this
|
||||
// is, so which kind it is changes nothing here.
|
||||
judgeIsQuick: () => undefined,
|
||||
restoreCursor: () => undefined,
|
||||
judge(event: ProgressEvent): void {
|
||||
if (event.kind === 'call-started') {
|
||||
open.set(event.call, {
|
||||
id: event.id,
|
||||
postings: event.postings,
|
||||
of: event.of,
|
||||
latest: NOTHING_YET,
|
||||
thinking: '',
|
||||
verdicts: 0,
|
||||
});
|
||||
if (namedTheRun) return;
|
||||
namedTheRun = true;
|
||||
runPostings = wholeRun(event);
|
||||
// The model and the company serving it are read off the first piece
|
||||
// the provider sent back rather than out of anything written down
|
||||
// here, because the model that answers is not always the model that
|
||||
// was asked for.
|
||||
say(`judging ${postingWord(runPostings)} with ${event.model} via ${event.provider}`);
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'verdict') {
|
||||
done += 1;
|
||||
const one = open.get(event.call);
|
||||
// A call carrying one posting names that posting. A screening call
|
||||
// carries a hundred, and naming them all would be a hundred lines a
|
||||
// coding agent has to store, so it says how many verdicts it produced
|
||||
// instead, once, when it has produced all of them.
|
||||
if (one === undefined || one.postings <= 1) {
|
||||
say(
|
||||
`verdict for ${shortId(event.id)}: ${event.verdict} ` +
|
||||
`(${done} of ${runPostings} done)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
one.verdicts += 1;
|
||||
if (one.verdicts >= one.postings) callDone(event.call, one);
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'call-failed') {
|
||||
say(`call ${event.call} of ${event.of} failed: ${event.reason}`);
|
||||
open.delete(event.call);
|
||||
}
|
||||
// The model's thinking, the running count of answer characters, and the
|
||||
// line saying a step of a routine has started are all left out. None of
|
||||
// them is something a coding agent can act on, and every one of them
|
||||
// would be a line it has to store.
|
||||
},
|
||||
finish(): void {
|
||||
// A screening call that answered for fewer postings than it carried
|
||||
// never reached the count that prints its line, so it is printed here
|
||||
// with the number of verdicts it really produced.
|
||||
for (const [call, one] of open) {
|
||||
if (one.postings > 1 && one.verdicts > 0) callDone(call, one);
|
||||
}
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Printing for a person
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const room = Math.max(1, options.columns - 1);
|
||||
// The redrawing library clips what it draws to the height the terminal
|
||||
// reports, and a terminal that reports a height of zero gets nothing drawn at
|
||||
// all. Some terminals really do report zero: one handed out by the `script`
|
||||
// program without a window size is one, and a person in that terminal would
|
||||
// watch a judge run in total silence with nothing failing anywhere. So the
|
||||
// stream handed to the library reports a sensible height whenever the real
|
||||
// one is zero or missing, and reports the real height otherwise. Everything
|
||||
// else about the stream, including every write, is the real stream's.
|
||||
const drawnOn = new Proxy(options.stderr as NodeJS.WriteStream, {
|
||||
get(real, name, receiver) {
|
||||
if (name === 'rows') {
|
||||
const height = (real as NodeJS.WriteStream).rows;
|
||||
return height === undefined || height <= 0 ? ASSUMED_HEIGHT : height;
|
||||
}
|
||||
const value = Reflect.get(real, name, receiver);
|
||||
return typeof value === 'function' ? value.bind(real) : value;
|
||||
},
|
||||
});
|
||||
const draw = createLogUpdate(drawnOn, {
|
||||
defaultWidth: options.columns,
|
||||
defaultHeight: ASSUMED_HEIGHT,
|
||||
});
|
||||
|
||||
/** True while anything at all is on the screen. */
|
||||
let drawn = false;
|
||||
/**
|
||||
* True once anything has been drawn at all, ever. The cursor is hidden by the
|
||||
* first thing drawn and not before, so a command that answered too quickly to
|
||||
* draw anything must not write the code that shows the cursor again either:
|
||||
* that code would be the last thing on the screen, sitting after the answer.
|
||||
*/
|
||||
let everDrawn = false;
|
||||
/** How many waits are open; the line comes off when the last one ends. */
|
||||
let waits = 0;
|
||||
/** What the waiting command is doing, and what it has got to. */
|
||||
let label = '';
|
||||
let detail: string | undefined;
|
||||
/**
|
||||
* What the second row under the waiting line says, or nothing when there is
|
||||
* no second row. It holds an explanation too long to sit after the clock, and
|
||||
* it is kept apart from the detail above so that setting one never takes the
|
||||
* other off the screen.
|
||||
*
|
||||
* It is held as something that is asked afresh on every redraw rather than as
|
||||
* a finished sentence, because a row that counts seconds down has to be built
|
||||
* again each time the clock has moved. A row set by `note` is a sentence that
|
||||
* never changes, and is held here as something that hands back that same
|
||||
* sentence every time.
|
||||
*/
|
||||
let underneath: (() => string) | undefined;
|
||||
/** When the first of the open waits began, which the clock counts from. */
|
||||
let waitStartedAt = 0;
|
||||
/** When the first thing happened in a judge run, which its clock counts from. */
|
||||
let judgeStartedAt: number | undefined;
|
||||
/** When the screen was last written to, so it is redrawn ten times a second. */
|
||||
let lastDrawnAt = Number.NEGATIVE_INFINITY;
|
||||
let redrawing: NodeJS.Timeout | undefined;
|
||||
|
||||
/** One row, cut to fit the window and ended with one ellipsis character. */
|
||||
const fit = (line: string): string =>
|
||||
line.length <= room ? line : `${line.slice(0, Math.max(0, room - 1))}${CUT_MARK}`;
|
||||
|
||||
/**
|
||||
* The last however-many characters of a piece of text that fit in the space
|
||||
* given, with one ellipsis character at the FRONT when the beginning had to be
|
||||
* left out.
|
||||
*
|
||||
* This is how the model's thinking is cut, and it is cut the other way round
|
||||
* from every other row here. The newest words of the thinking are the ones a
|
||||
* person wants to read, so the row keeps the end of the text and drops the
|
||||
* beginning, and the words slide leftwards off the row as new ones arrive.
|
||||
* Every other row keeps its beginning and drops its end, which is what `fit`
|
||||
* above does.
|
||||
*/
|
||||
const lastPartThatFits = (text: string, space: number): string => {
|
||||
if (space <= 0) return '';
|
||||
if (text.length <= space) return text;
|
||||
if (space === 1) return CUT_MARK;
|
||||
return `${CUT_MARK}${text.slice(text.length - (space - 1))}`;
|
||||
};
|
||||
|
||||
/** The rows of the group drawn while a judge run goes. */
|
||||
const judgeRows = (): string[] => {
|
||||
const sinceTheRunBegan = now() - (judgeStartedAt ?? now());
|
||||
const running = clockText(sinceTheRunBegan);
|
||||
// A quick screen sends up to a hundred postings in one model call, so the
|
||||
// count of finished postings sits at 0 for the whole run and then jumps
|
||||
// straight to the total when that one call comes back. Andrew ran a quick
|
||||
// screen of thirty postings at his terminal on 2026-08-20 and watched it do
|
||||
// that, so the count is left off a quick screen's row altogether and the row
|
||||
// carries what does move: the calls that failed and the clock. An ordinary
|
||||
// judge run makes one call per posting, where the count rises as the run
|
||||
// goes, and its row is unchanged.
|
||||
const counted = quickRun
|
||||
? `${FAILED_MARK} ${failedCalls} failed ${running}`
|
||||
: `${DONE_MARK} ${done} of ${runPostings} done ` +
|
||||
`${FAILED_MARK} ${failedCalls} failed ${running}`;
|
||||
// A window too narrow for a row of columns gets the one row that matters.
|
||||
if (options.columns < NARROW_WINDOW) return [fit(counted)];
|
||||
|
||||
const naming =
|
||||
`judging ${postingWord(runPostings)}, ${MAX_IN_FLIGHT_REQUESTS} at a time, ${runModel}`;
|
||||
const calls = [...open.values()]
|
||||
.slice(0, MAX_IN_FLIGHT_REQUESTS)
|
||||
.map((one) => {
|
||||
const named = shortId(one.id);
|
||||
// The row of a running call starts with the turning character, so a call
|
||||
// the model is saying nothing on still moves. The tick and the cross on
|
||||
// the row below are marks of something that has already happened, so
|
||||
// they stay the fixed characters they are.
|
||||
const head = `${turningAt(sinceTheRunBegan)} ${named === '' ? '' : `${named} `}`;
|
||||
const thought = one.thinking.trimEnd();
|
||||
if (thought === '') return `${head}${one.latest}`;
|
||||
// The turning character, the posting and the word "thinking:" always
|
||||
// stay on the row; what is left of the window's width is filled with the
|
||||
// end of what the model has thought so far.
|
||||
const said = `${head}thinking: `;
|
||||
return `${said}${lastPartThatFits(thought, room - said.length)}`;
|
||||
});
|
||||
return [naming, ...calls, counted].map(fit);
|
||||
};
|
||||
|
||||
/** What should be on the screen right now, as rows. */
|
||||
const frame = (): string[] => {
|
||||
if (judgeStartedAt !== undefined) return judgeRows();
|
||||
if (waits === 0) return [];
|
||||
const waited = now() - waitStartedAt;
|
||||
// A command that answers quickly leaves no trace of having waited at all.
|
||||
if (waited < QUIET_FIRST_MS) return [];
|
||||
const waiting = fit(`${turningAt(waited)} ${label}${CUT_MARK} ${detail ?? clockText(waited)}`);
|
||||
const explanation = underneath?.();
|
||||
if (explanation === undefined) return [waiting];
|
||||
// The second row is indented by two spaces so a person reads it as
|
||||
// belonging to the line above it. It is cut to the window by the same rule
|
||||
// as every other row, because the group is erased by counting the rows it
|
||||
// drew, and a row too long for the window is carried onto a second screen
|
||||
// row by the terminal itself, which makes that count wrong.
|
||||
return [waiting, fit(` ${explanation}`)];
|
||||
};
|
||||
|
||||
/**
|
||||
* How many whole seconds a wait that ends at the given moment still has to
|
||||
* run, as the row underneath the waiting line says it.
|
||||
*
|
||||
* The number is rounded up, so a wait with two and a half seconds left reads
|
||||
* 3s, and it never falls below 1: while there is any time left at all the row
|
||||
* reads 1s rather than 0s, and the command that is waiting replaces the row
|
||||
* with the words saying its request has gone out again the moment the wait
|
||||
* ends.
|
||||
*/
|
||||
const secondsLeftUntil = (endsAt: number): number =>
|
||||
Math.max(1, Math.ceil((endsAt - now()) / 1_000));
|
||||
|
||||
/**
|
||||
* Puts what should be on the screen on the screen.
|
||||
*
|
||||
* `insist` is for the two moments where the screen has to be right this
|
||||
* instant rather than at the next redraw: a wait ending, and the whole command
|
||||
* finishing. Everything else is held to ten redraws a second, because a judge
|
||||
* run produces a piece of the model's thinking every few milliseconds and
|
||||
* redrawing on every one of them makes the screen flicker.
|
||||
*/
|
||||
const render = (insist = false): void => {
|
||||
if (!insist && now() - lastDrawnAt < REDRAW_EVERY_MS) return;
|
||||
const rows = frame();
|
||||
if (rows.length === 0) {
|
||||
if (drawn) {
|
||||
draw.clear();
|
||||
drawn = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastDrawnAt = now();
|
||||
draw(rows.join('\n'));
|
||||
drawn = true;
|
||||
everDrawn = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps the clock and the turning character on the screen moving even when
|
||||
* nothing else happens.
|
||||
*
|
||||
* This redraw insists, where a redraw set off by something the model sent does
|
||||
* not. The gap between two of these redraws and the gap the throttle above
|
||||
* asks for are both a tenth of a second, and a timer that fires a fraction of
|
||||
* a millisecond early would be turned away by the throttle every second or
|
||||
* third time, which would leave the turning character stopped for a fifth of a
|
||||
* second at a stretch. There is only one of these timers and it fires ten
|
||||
* times a second, so nothing here can flicker.
|
||||
*/
|
||||
const keepRedrawing = (): void => {
|
||||
if (redrawing !== undefined) return;
|
||||
redrawing = setInterval(() => render(true), REDRAW_EVERY_MS);
|
||||
// A timer must never be the reason this command stays alive.
|
||||
redrawing.unref?.();
|
||||
};
|
||||
|
||||
const stopRedrawing = (): void => {
|
||||
if (redrawing === undefined) return;
|
||||
clearInterval(redrawing);
|
||||
redrawing = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
colours,
|
||||
wait(waitingFor: string): void {
|
||||
if (waits === 0) {
|
||||
waitStartedAt = now();
|
||||
detail = undefined;
|
||||
underneath = undefined;
|
||||
}
|
||||
waits += 1;
|
||||
label = waitingFor;
|
||||
keepRedrawing();
|
||||
render();
|
||||
},
|
||||
relabel(waitingFor: string): void {
|
||||
label = waitingFor;
|
||||
detail = undefined;
|
||||
// The same wait is doing something else now, so an explanation of what
|
||||
// held up the thing it was doing before is out of date and comes off.
|
||||
underneath = undefined;
|
||||
render();
|
||||
},
|
||||
update(hasGotTo: string): void {
|
||||
detail = hasGotTo;
|
||||
render();
|
||||
},
|
||||
note(saying: string | undefined): void {
|
||||
underneath = saying === undefined ? undefined : (): string => saying;
|
||||
// This redraw insists rather than waiting for the next one, because the
|
||||
// one sentence this row carries at the end of a wait says the request has
|
||||
// gone out again this instant, and a server that refuses it again a few
|
||||
// milliseconds later would otherwise take that sentence off the screen
|
||||
// before it was ever drawn. Measured on 2026-08-20 against the real
|
||||
// server, waiting for the next redraw instead: of the 19 pauses in one
|
||||
// run, the sentence reached the screen on one of them. Insisting here
|
||||
// put it on the screen at 18 of those 19, the last being the pause the
|
||||
// run was killed in the middle of.
|
||||
// Nothing on this row arrives fast enough for insisting to make the
|
||||
// screen flicker; the model's thinking, which does, goes nowhere near it.
|
||||
render(true);
|
||||
},
|
||||
countdown(milliseconds: number, wording: (secondsLeft: number) => string): void {
|
||||
// The moment the wait is over is worked out once, here, and the row is
|
||||
// built from the clock on every redraw. So the number on the row falls as
|
||||
// the wait runs out, without this file holding a timer of its own: the
|
||||
// redraw that already runs ten times a second is what moves it.
|
||||
const endsAt = now() + Math.max(0, milliseconds);
|
||||
underneath = (): string => wording(secondsLeftUntil(endsAt));
|
||||
render();
|
||||
},
|
||||
stop(): void {
|
||||
waits = Math.max(0, waits - 1);
|
||||
if (waits > 0) return;
|
||||
detail = undefined;
|
||||
underneath = undefined;
|
||||
render(true);
|
||||
},
|
||||
judgeIsQuick(quick: boolean): void {
|
||||
quickRun = quick;
|
||||
},
|
||||
judge(event: ProgressEvent): void {
|
||||
if (event.kind === 'step-started' || event.kind === 'error') return;
|
||||
judgeStartedAt ??= now();
|
||||
keepRedrawing();
|
||||
|
||||
if (event.kind === 'call-started') {
|
||||
runModel = event.model;
|
||||
if (runPostings === 0) runPostings = wholeRun(event);
|
||||
open.set(event.call, {
|
||||
id: event.id,
|
||||
postings: event.postings,
|
||||
of: event.of,
|
||||
latest: NOTHING_YET,
|
||||
thinking: '',
|
||||
verdicts: 0,
|
||||
});
|
||||
} else if (event.kind === 'thinking') {
|
||||
const one = open.get(event.call);
|
||||
// The provider sends the thinking in tiny pieces, often a few characters
|
||||
// and sometimes a single letter. Keeping only the newest piece, which is
|
||||
// what this did until 2026-08-19, made the row read as noise: a row
|
||||
// saying "thinking: A" and a moment later "thinking: is a no. He's
|
||||
// also". So every piece is added to the end of what this call has
|
||||
// already thought, and the row shows the end of the joined text.
|
||||
if (one) {
|
||||
const joined = oneLine(`${one.thinking}${event.text}`);
|
||||
one.thinking =
|
||||
joined.length <= THINKING_KEPT ? joined : joined.slice(joined.length - THINKING_KEPT);
|
||||
}
|
||||
} else if (event.kind === 'answer') {
|
||||
const one = open.get(event.call);
|
||||
// The answer itself takes the row over, so the thinking behind it is
|
||||
// thrown away rather than kept for a row that will not show it again.
|
||||
if (one) {
|
||||
one.latest = `answer ${withSeparators(event.chars)} chars`;
|
||||
one.thinking = '';
|
||||
}
|
||||
} else if (event.kind === 'verdict') {
|
||||
done += 1;
|
||||
// The call's row comes off as soon as the call has produced everything
|
||||
// it was carrying. The verdict itself is never printed here: the group
|
||||
// of lines is the only thing that moves while the run is going, and
|
||||
// every verdict is printed by the judge command when the run has ended
|
||||
// (Andrew, 2026-08-19).
|
||||
const one = open.get(event.call);
|
||||
if (one) {
|
||||
one.verdicts += 1;
|
||||
if (one.verdicts >= one.postings) open.delete(event.call);
|
||||
}
|
||||
} else if (event.kind === 'call-failed') {
|
||||
failedCalls += 1;
|
||||
open.delete(event.call);
|
||||
}
|
||||
render();
|
||||
},
|
||||
finish(): void {
|
||||
stopRedrawing();
|
||||
if (drawn) {
|
||||
draw.clear();
|
||||
drawn = false;
|
||||
}
|
||||
// Gives the cursor back, which the redrawing hid. Nothing was hidden if
|
||||
// nothing was ever drawn, so nothing is written in that case.
|
||||
if (everDrawn) draw.done();
|
||||
everDrawn = false;
|
||||
waits = 0;
|
||||
judgeStartedAt = undefined;
|
||||
open.clear();
|
||||
},
|
||||
restoreCursor(): void {
|
||||
options.stderr.write(SHOW_CURSOR);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The version number this copy of the `pinloop` command reports about itself.
|
||||
*
|
||||
* Why this file exists. Once `pinloop` is installed from npm onto somebody
|
||||
* else's machine, nobody here can reach it. A copy installed in September keeps
|
||||
* running in November exactly as it was, until the person who installed it
|
||||
* chooses to install it again. The one thing that copy can do is say which
|
||||
* version it is, on every request it sends, so the server can answer with the
|
||||
* newest published version and the copy can print a line telling its owner that
|
||||
* a newer copy exists. This constant is that number.
|
||||
*
|
||||
* What breaks silently if this number is wrong. The number below has to be the
|
||||
* same number the published package carries. scripts/pack-cli.mjs reads the
|
||||
* `version` field of the repository's package.json and copies it straight into
|
||||
* the package people install, so if package.json were raised to 0.2.0 and this
|
||||
* constant were left at 0.1.0, every installed copy of 0.2.0 would report 0.1.0
|
||||
* to the server. The server would compare 0.1.0 against a newest published
|
||||
* version of 0.2.0, decide those people were out of date, and print the update
|
||||
* line at people who had already updated. Nothing would fail, and installing
|
||||
* again would not stop the line, because installing again would not change the
|
||||
* number being reported. A test in src/cli/cli-version.test.ts reads both this
|
||||
* constant and package.json and fails the moment the two stop matching.
|
||||
*
|
||||
* This file lives in src/cli because the published package is built out of
|
||||
* src/cli and src/shared alone. It imports nothing, and reading package.json at
|
||||
* run time is deliberately not done here: the published package's own
|
||||
* package.json sits at a different place relative to this file than the
|
||||
* repository's does, so a file read would be a thing that works in the test
|
||||
* suite and breaks in the thing people install.
|
||||
*/
|
||||
|
||||
/** The version this copy of the command is. Must equal package.json's `version`. */
|
||||
export const CLI_VERSION = '0.3.0';
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The names paying Pinloop twenty dollars a month is written from: the four
|
||||
* addresses on the server, the two pages on pinloop.ai, the two names carried
|
||||
* inside a web address, how long a one-time code is good for, and the two lines
|
||||
* `pinloop billing` prints.
|
||||
*
|
||||
* Nothing here is implementation. Every name below is an address, a number or a
|
||||
* printed sentence that the installed `pinloop` command, the server and the
|
||||
* tests all read out of this one file, so that none of the three carries its own
|
||||
* copy and drifts from the other two. It is the same arrangement
|
||||
* src/shared/sign-in.ts has held since browser sign-in shipped.
|
||||
*
|
||||
* The feature is specs/feature-subscription.md, DRAFT 2026-08-21, with the
|
||||
* architecture approved the same day. In one paragraph: `pinloop billing` asks
|
||||
* the server for a web address and opens it. An account that pays nothing is
|
||||
* handed Pinloop's own payment page at pinloop.ai/upgrade, carrying a one-time
|
||||
* code the server parked in its memory. The page's script trades that code, once,
|
||||
* for the secret Stripe's own card form needs, and Stripe's script draws the
|
||||
* card form inside a sealed frame on that page. An account that already pays is
|
||||
* handed an address at Stripe instead, which is where cancelling a subscription
|
||||
* and replacing a card happen.
|
||||
*
|
||||
* This file lives in src/shared because both the installed command and the
|
||||
* server read it: src/cli/package-boundary.test.ts allows the published command
|
||||
* to be built out of src/cli and src/shared and nothing else.
|
||||
*/
|
||||
import { ADDRESS_INDENT } from './sign-in.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The four addresses on the server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where `pinloop billing` asks for the address a person should open in a
|
||||
* browser. It requires a pass, like every other command.
|
||||
*
|
||||
* The answer is one field, `url`. For an account with no live subscription it is
|
||||
* the payment page on pinloop.ai carrying a one-time code; for an account with
|
||||
* one it is an address at Stripe.
|
||||
*/
|
||||
export const BILLING_PATH = '/billing';
|
||||
|
||||
/**
|
||||
* Where the payment page's script trades the one-time code, once, for the secret
|
||||
* Stripe's card form needs. It takes no pass at all: a browser does the trading,
|
||||
* and the browser holds no Pinloop login.
|
||||
*
|
||||
* The answer carries `client_secret` (what Stripe's script in the browser needs
|
||||
* to draw the card form), `publishable_key` (the key that names the Stripe
|
||||
* account, which is meant to be public), and `checkout` (the one-time value the
|
||||
* finished page asks about below).
|
||||
*/
|
||||
export const BILLING_TRADE_PATH = '/billing/trade';
|
||||
|
||||
/**
|
||||
* Where the page a person lands on after paying asks how the payment stands. It
|
||||
* takes no pass either, and answers `{"finished": true|false}`.
|
||||
*
|
||||
* The value it is handed is NOT Stripe's own identifier for the checkout. It is
|
||||
* a one-time value the server invented when it created that checkout and
|
||||
* remembers alongside Stripe's identifier for it, and the server answers only
|
||||
* for a value it invented itself.
|
||||
*/
|
||||
export const BILLING_STATUS_PATH = '/billing/status';
|
||||
|
||||
/**
|
||||
* Where Stripe delivers its own messages about finished payments, renewals,
|
||||
* failed charges and cancellations. It takes no pass: the signature Stripe puts
|
||||
* in the `stripe-signature` request header stands in for one, and a body whose
|
||||
* signature does not check out is refused.
|
||||
*/
|
||||
export const STRIPE_NOTICE_PATH = '/stripe/notice';
|
||||
|
||||
/** The request header Stripe puts its signature in. */
|
||||
export const STRIPE_SIGNATURE_HEADER = 'stripe-signature';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The two pages on pinloop.ai
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The page a person who pays nothing types their card into. */
|
||||
export const UPGRADE_PAGE_PATH = '/upgrade';
|
||||
|
||||
/** The page Stripe sends the browser back to when the card form is finished. */
|
||||
export const UPGRADE_DONE_PAGE_PATH = '/upgrade/done';
|
||||
|
||||
/** The name in the payment page's own address that carries the one-time code. */
|
||||
export const BILLING_CODE_PARAMETER = 'code';
|
||||
|
||||
/**
|
||||
* The name in the finished page's address that carries the one-time value the
|
||||
* server invented for one checkout.
|
||||
*/
|
||||
export const CHECKOUT_PARAMETER = 'checkout';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// How long a parked code lives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* How long the one-time code in the printed address can be traded for a card
|
||||
* form: ten minutes, and once.
|
||||
*
|
||||
* It is the same ten minutes a sign-in's short code is good for, for the same
|
||||
* reason: long enough that a person can walk to another machine and open the
|
||||
* page there, short enough that a code read off a screen is worthless by the
|
||||
* time anybody could go looking for it.
|
||||
*/
|
||||
export const BILLING_CODE_LIFETIME_MS = 10 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The two lines `pinloop billing` prints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The line printed above the address, with the address on its own indented line
|
||||
* under it.
|
||||
*
|
||||
* It names no price and no amount of money, because nothing this product prints
|
||||
* ever does (.claude/skills/printed-message/SKILL.md, rule 1). It is true
|
||||
* whichever of the two addresses the server hands back, because the terminal
|
||||
* does not know which it got: setting up a subscription happens on Pinloop's own
|
||||
* payment page, and changing or cancelling one happens on Stripe's page, and
|
||||
* "set up or change" covers both.
|
||||
*/
|
||||
export const BILLING_OPEN_LINE =
|
||||
'open this address in your browser to set up or change your Pinloop subscription:';
|
||||
|
||||
/**
|
||||
* The two lines together, ready to be written to standard output: the sentence,
|
||||
* then the address alone on an indented line.
|
||||
*
|
||||
* The address is always printed, even on a machine where the command also opens
|
||||
* the browser itself, because the person running the command is often a coding
|
||||
* agent while the human is looking at a browser on another screen, and because a
|
||||
* rented machine reached over SSH has no browser at all. `pinloop login` prints
|
||||
* its address the same way and for the same reasons.
|
||||
*/
|
||||
export function billingLines(url: string): string {
|
||||
return `${BILLING_OPEN_LINE}\n${ADDRESS_INDENT}${url}\n`;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* How much of what somebody asked for was actually covered, and the one way this
|
||||
* product says it.
|
||||
*
|
||||
* Andrew's rule, 2026-08-15. Whenever this product tells somebody that something
|
||||
* was left out, failed, dropped, skipped or not found, the first thing it says
|
||||
* is how much of the whole did come through, written as a fraction of the real
|
||||
* numbers. Not "4 postings without embeddings were not considered", but
|
||||
* "996/1000 postings had embeddings and were considered". The fraction goes
|
||||
* first, and the list of which ones and why goes after it.
|
||||
*
|
||||
* Two things about the numbers. They are never reduced: 249 out of 250 is
|
||||
* written 249/250, not 249/250 turned into anything smaller, and 996 out of 1000
|
||||
* stays 996/1000 however large the numbers get. And when there was nothing to
|
||||
* cover in the first place, the answer is "0/0" rather than a division nobody
|
||||
* can do.
|
||||
*
|
||||
* In an answer a program reads, the two numbers travel as plain whole numbers in
|
||||
* an object of exactly this shape, sitting next to whatever the answer already
|
||||
* carried:
|
||||
*
|
||||
* "coverage": { "covered": 996, "total": 1000 }
|
||||
*
|
||||
* Nothing that was already in an answer was taken away to make room for it, so a
|
||||
* program that reads `not_found` or `dropped` today still reads them.
|
||||
*
|
||||
* In text a person reads, the same two numbers are written with thousands
|
||||
* separators, because 149,446/149,519 can be read at a glance and 149446/149519
|
||||
* cannot. Writing them that way is the command line's job and lives in
|
||||
* src/cli/format.ts: the server hands back the two whole numbers and nothing
|
||||
* else, and only the program a person is looking at turns them into a sentence.
|
||||
*
|
||||
* This file sits under src/shared/ because both sides run it. The server works
|
||||
* the numbers out; the command line reads them off an answer and prints them.
|
||||
* Nothing here opens a connection, reads a credential or writes SQL, which is
|
||||
* what lets the published `pinloop` package carry it without carrying the
|
||||
* server.
|
||||
*/
|
||||
|
||||
/** How many of a set were covered, and how many there were in all. */
|
||||
export type Coverage = {
|
||||
/** How many came through: the successes. */
|
||||
covered: number;
|
||||
/** How many there were altogether, the covered ones included. */
|
||||
total: number;
|
||||
};
|
||||
|
||||
/** The two numbers, in the one shape every answer in this product uses. */
|
||||
export function coverageOf(covered: number, total: number): Coverage {
|
||||
return { covered, total };
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* The filter verb: keep the rows that satisfy every rule, and say why each of
|
||||
* the others was dropped.
|
||||
*
|
||||
* This file deliberately knows nothing about where rows came from. It is handed
|
||||
* rows that some other verb already produced, it reads only the fields those
|
||||
* rows carry, and it hands back two lists. Nothing here opens a connection,
|
||||
* reads a credential, or writes SQL — a run of `pinloop filter` works with the
|
||||
* network unplugged.
|
||||
*
|
||||
* The rules speak the vocabulary a person types on the command line — country,
|
||||
* workplace, employment, posted-after — and the rows carry the column names the
|
||||
* database stores: countries (a list), workplace_type (one value),
|
||||
* employment_type (a list), posted_at (a timestamp).
|
||||
*
|
||||
* Two promises hold for every run. Each row handed in comes back exactly once,
|
||||
* either among the survivors or in the drop report, so a row can never go
|
||||
* missing quietly. And when a row is dropped, the report names the rule that
|
||||
* dropped it, so a person reading standard error knows which condition to relax.
|
||||
* Every run also says how many rows satisfied every rule out of how many were
|
||||
* handed in, under `coverage`, and that fraction is the first thing the drop
|
||||
* report says (src/shared/coverage.ts).
|
||||
* A row that carries nothing at all for the field a rule reads is dropped by
|
||||
* that rule, because a rule about countries cannot be satisfied by a row that
|
||||
* stores no countries.
|
||||
*/
|
||||
|
||||
import { coverageOf, type Coverage } from './coverage.ts';
|
||||
|
||||
/** The conditions a person asked for, in the words they typed them in. */
|
||||
export type FilterRules = {
|
||||
country?: string | undefined;
|
||||
workplace?: string | undefined;
|
||||
employment?: string | undefined;
|
||||
posted_after?: string | undefined;
|
||||
};
|
||||
|
||||
/** One row that did not survive, and the rule it failed. */
|
||||
export type FilterDrop = {
|
||||
id: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/** What one run of the filter produced. */
|
||||
export type FilterAnswer<Row> = {
|
||||
survivors: Row[];
|
||||
dropped: FilterDrop[];
|
||||
/** How many rows satisfied every rule, out of how many were handed in. */
|
||||
coverage: Coverage;
|
||||
};
|
||||
|
||||
/** The fields of a row this file reads, all of them optional. */
|
||||
type StoredFields = {
|
||||
id?: unknown;
|
||||
countries?: unknown;
|
||||
workplace_type?: unknown;
|
||||
employment_type?: unknown;
|
||||
posted_at?: unknown;
|
||||
};
|
||||
|
||||
/** A list of strings out of a stored field, or undefined when there is no list. */
|
||||
function storedList(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value)) return value.map((one) => String(one));
|
||||
if (typeof value === 'string' && value !== '') return [value];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A moment in time out of a stored value, or undefined when there is none. */
|
||||
function storedMoment(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
const when = new Date(value as string | number | Date).getTime();
|
||||
return Number.isNaN(when) ? undefined : when;
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a posted-after rule compares against, or a refusal in the words a
|
||||
* person reads when what they typed is not a date.
|
||||
*
|
||||
* This is separate from the walk over the rows below because it is the one part
|
||||
* of a filter rule that can be wrong on its own, before any row is looked at.
|
||||
* The routines feature checks a stored filter step's arguments at the moment the
|
||||
* routine is stored, when there are no rows to walk, and it calls this.
|
||||
*/
|
||||
export function cutoffInstant(postedAfter: string): number {
|
||||
const cutoff = new Date(postedAfter).getTime();
|
||||
if (Number.isNaN(cutoff)) {
|
||||
throw new Error('posted-after must be a date written as YYYY-MM-DD (for example 2026-06-01)');
|
||||
}
|
||||
return cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one rule this row fails, or undefined when it passes them all. The rules
|
||||
* are checked in a fixed order — country, workplace, employment, posted-after —
|
||||
* so a row failing two of them is always reported against the same one, and two
|
||||
* runs of the same command say the same thing.
|
||||
*/
|
||||
function firstRuleThisRowFails(row: StoredFields, rules: FilterRules): string | undefined {
|
||||
if (rules.country !== undefined && rules.country !== '') {
|
||||
const countries = storedList(row.countries);
|
||||
if (countries === undefined) {
|
||||
return `the country rule: this posting stores no countries at all, so it cannot be in ${rules.country}`;
|
||||
}
|
||||
if (!countries.includes(rules.country)) {
|
||||
return `the country rule: this posting's countries are ${countries.join(', ')}, not ${rules.country}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.workplace !== undefined && rules.workplace !== '') {
|
||||
const workplace = row.workplace_type;
|
||||
if (workplace === null || workplace === undefined || workplace === '') {
|
||||
return `the workplace rule: this posting stores no workplace kind, so it cannot be ${rules.workplace}`;
|
||||
}
|
||||
if (String(workplace) !== rules.workplace) {
|
||||
return `the workplace rule: this posting's workplace is ${String(workplace)}, not ${rules.workplace}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.employment !== undefined && rules.employment !== '') {
|
||||
const employment = storedList(row.employment_type);
|
||||
if (employment === undefined) {
|
||||
return `the employment rule: this posting stores no employment label at all, so it cannot be ${rules.employment}`;
|
||||
}
|
||||
if (!employment.includes(rules.employment)) {
|
||||
return `the employment rule: this posting's employment labels are ${employment.join(', ')}, not ${rules.employment}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.posted_after !== undefined && rules.posted_after !== '') {
|
||||
const cutoff = cutoffInstant(rules.posted_after);
|
||||
const when = storedMoment(row.posted_at);
|
||||
if (when === undefined) {
|
||||
return `the posted-after rule: this posting stores no posted date, so it cannot be on or after ${rules.posted_after}`;
|
||||
}
|
||||
if (when < cutoff) {
|
||||
return `the posted-after rule: this posting was posted ${String(row.posted_at)}, before ${rules.posted_after}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the rows into the ones that satisfy every rule and the ones that do
|
||||
* not, with a plain sentence for each drop. Takes rows and rules, and nothing
|
||||
* else, and returns as soon as it has walked the rows once.
|
||||
*/
|
||||
export function runFilter<Row>(rows: readonly Row[], rules: FilterRules): FilterAnswer<Row> {
|
||||
const survivors: Row[] = [];
|
||||
const dropped: FilterDrop[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const reason = firstRuleThisRowFails((row ?? {}) as StoredFields, rules);
|
||||
if (reason === undefined) {
|
||||
survivors.push(row);
|
||||
} else {
|
||||
dropped.push({ id: String((row as StoredFields | null)?.id ?? ''), reason });
|
||||
}
|
||||
}
|
||||
|
||||
return { survivors, dropped, coverage: coverageOf(survivors.length, rows.length) };
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
/**
|
||||
* The written text `pinloop guide` prints: one entry per command, plus the
|
||||
* sections that are not about any one command.
|
||||
*
|
||||
* The instructions are not one long document typed out by hand. The builder in
|
||||
* src/shared/guide.ts walks the real command tree the installed program builds
|
||||
* and looks up, for each command it finds, the entry here keyed by that
|
||||
* command's name. The names are spelled exactly the way src/core/catalog.ts
|
||||
* spells them, with the words joined by single spaces: `search`, `profile put`,
|
||||
* `tab add`. A command with no entry here makes src/shared/guide.test.ts fail
|
||||
* naming that command, so nobody can add a command to Pinloop and leave it
|
||||
* undocumented by accident.
|
||||
*
|
||||
* Three things are deliberately not in this file. There is no count of what one
|
||||
* account has spent this month, because a number written here would be compiled
|
||||
* into a copy of the program somebody installed weeks ago and would be wrong;
|
||||
* the guide names `pinloop` typed on its own as the place those numbers are
|
||||
* read. There are no dollar amounts anywhere. And there is no instruction
|
||||
* telling an agent to ask the person before spending money (Andrew, 2026-08-18,
|
||||
* emphatic, reaffirmed 2026-08-19): what protects the person's money is the
|
||||
* allowance the server enforces, and a sentence asking permission protects
|
||||
* nobody while making every run slower.
|
||||
*
|
||||
* The two numbers the "limits" section does state — how many requests an
|
||||
* account may make in a minute, and the most postings one command may pull —
|
||||
* are read from src/shared/limits.ts rather than typed out here, so that a copy
|
||||
* of them cannot go on saying 60 after the server has moved to 120
|
||||
* (specs/feature-pull-ceiling.md, section 7).
|
||||
*
|
||||
* Lane L on the pre-launch board wrote every sentence below, finished 2026-08-21
|
||||
* once the two lanes that still touched the command surface had settled their
|
||||
* shapes: the monthly allowances on branch judging-allowance, and the
|
||||
* subscription command on branch subscription. The entry keyed `billing` is
|
||||
* written for a command those branches add. Until one of them ships it, the walk
|
||||
* finds no such command and the entry is simply never printed, which is why
|
||||
* writing it early breaks nothing.
|
||||
*
|
||||
* The five worked examples in the `examples` section are real runs against
|
||||
* https://api.pinloop.ai on 2026-08-21, pasted as they came back. Where output
|
||||
* was cut short the text says so. Two mistakes in the draft they replaced were
|
||||
* found by running it: the draft told an agent to type `--country CA`, which
|
||||
* matches nothing because postings carry a country's full name, and it spelled a
|
||||
* routine's search step `words` when the argument is `q`.
|
||||
*/
|
||||
import {
|
||||
MAX_MESSAGE_CHARS,
|
||||
MESSAGES_PER_DAY,
|
||||
PULL_CEILING,
|
||||
RATE_LIMIT_REQUESTS,
|
||||
} from './limits.ts';
|
||||
|
||||
/**
|
||||
* The two numbers the message section states out loud, written the way a person
|
||||
* reads them: ten thousand as 10,000 rather than 10000.
|
||||
*
|
||||
* They are built from the constants the server enforces rather than typed here,
|
||||
* so a change to either number changes both the limit and the sentence that
|
||||
* describes it. The locale is named rather than left to the machine, so the
|
||||
* printed text is the same on every machine.
|
||||
*/
|
||||
const inFigures = (howMany: number): string => howMany.toLocaleString('en-US');
|
||||
|
||||
/**
|
||||
* One of the two monthly counts, exactly as the server hands it over: how many
|
||||
* of that count this account has used this month, what its limit is or the word
|
||||
* saying it has none, and the day the count returns to zero.
|
||||
*/
|
||||
export type PrintableAllowance = {
|
||||
used: number;
|
||||
limit: number | string;
|
||||
resets_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The word the server sends in place of a number for a count this account has no
|
||||
* limit on.
|
||||
*/
|
||||
const NO_LIMIT = 'unlimited';
|
||||
|
||||
/**
|
||||
* The two lines the bare `pinloop` command prints about this month's judging and
|
||||
* meaning-based searching, built out of the numbers the server sent.
|
||||
*
|
||||
* Every number in them comes from the server (specs/feature-judging-allowance.md,
|
||||
* AGREED 2026-08-21, architecture point 8). Nothing here holds a copy of any
|
||||
* limit, so the day Andrew changes one, every copy of the command anybody has
|
||||
* already installed starts printing the new one without being upgraded.
|
||||
*
|
||||
* A count with no limit prints what it has used and says there is no limit,
|
||||
* rather than a fraction of nothing. A count whose numbers the server did not
|
||||
* send — an older server answering a newer command — prints no line at all,
|
||||
* which is better than a line built out of guesses.
|
||||
*/
|
||||
export function allowanceLines(allowances: unknown): string[] {
|
||||
const held = (allowances ?? {}) as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
const judge = slotIn(held['judge']);
|
||||
const semantic = slotIn(held['semantic']);
|
||||
const fullJudgmentCosts = costOfOneFullJudgment(held['quick_postings_per_full_judgment']);
|
||||
if (judge) {
|
||||
const left = leftOf(judge);
|
||||
// How many of those postings this account may still have judged on its own,
|
||||
// with the whole job description sent to the model. It is the figure above
|
||||
// divided by what the server said one such judgment costs, rounded down
|
||||
// (specs/feature-quick-judging-limits.md, AGREED 2026-08-22). Rounded down,
|
||||
// because a person told they may judge two postings and then refused the
|
||||
// second has been lied to; and no fraction is ever printed, because "1.28
|
||||
// full judgments" teaches a person nothing.
|
||||
const fullJudgments =
|
||||
fullJudgmentCosts === undefined ? undefined : Math.floor(left / fullJudgmentCosts);
|
||||
lines.push(
|
||||
judge.limit === NO_LIMIT
|
||||
? `Judging: ${judge.used} judged postings this month, no limit`
|
||||
: `Judging: ${left} of ${judge.limit} judged postings left this month` +
|
||||
(fullJudgments === undefined
|
||||
? ''
|
||||
: `, or ${fullJudgments} judged one at a time`) +
|
||||
`, resets ${judge.resets_at}`,
|
||||
);
|
||||
}
|
||||
if (semantic) {
|
||||
lines.push(
|
||||
semantic.limit === NO_LIMIT
|
||||
? `Search by meaning: ${semantic.used} searches by meaning this month, no limit`
|
||||
: `Search by meaning: ${leftOf(semantic)} of ${semantic.limit} left this month, ` +
|
||||
`resets ${semantic.resets_at}`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one posting judged on its own costs out of the judging count, exactly as
|
||||
* the server sent it (specs/feature-quick-judging-limits.md, AGREED
|
||||
* 2026-08-22).
|
||||
*
|
||||
* The installed command holds no copy of this number, for the same reason it
|
||||
* holds no copy of any limit: a number typed in here would go on being printed
|
||||
* after Andrew changed it, on every copy anybody had already installed. A server
|
||||
* that sent nothing usable — an older server answering a newer command — makes
|
||||
* this undefined, and the judging line then prints the one figure it can be sure
|
||||
* of rather than a second figure built out of a guess.
|
||||
*/
|
||||
function costOfOneFullJudgment(value: unknown): number | undefined {
|
||||
const cost = Number(value);
|
||||
if (!Number.isFinite(cost) || cost < 1) return undefined;
|
||||
return Math.floor(cost);
|
||||
}
|
||||
|
||||
/** One of the two counts, when the server really sent all three of its parts. */
|
||||
function slotIn(value: unknown): PrintableAllowance | undefined {
|
||||
if (value === null || typeof value !== 'object') return undefined;
|
||||
const held = value as Record<string, unknown>;
|
||||
const used = Number(held['used']);
|
||||
const limit = held['limit'];
|
||||
const resetsAt = held['resets_at'];
|
||||
if (!Number.isFinite(used)) return undefined;
|
||||
if (typeof limit !== 'number' && typeof limit !== 'string') return undefined;
|
||||
if (typeof resetsAt !== 'string') return undefined;
|
||||
return { used, limit, resets_at: resetsAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of one count are left this month. It never goes below zero: a count
|
||||
* that somehow sits past its limit has none left, and a negative number in front
|
||||
* of a person would only read as a mistake.
|
||||
*/
|
||||
function leftOf(slot: PrintableAllowance): number {
|
||||
const limit = Number(slot.limit);
|
||||
if (!Number.isFinite(limit)) return 0;
|
||||
return Math.max(0, limit - slot.used);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sentence printed at the very bottom of the guide when nobody is
|
||||
* signed in, set apart by a blank line.
|
||||
*
|
||||
* It sits at the bottom rather than the top because somebody who has just run
|
||||
* the guide is looking at the end of their screen, not the start of it. It names
|
||||
* the command to run, because a person who reads it and cannot see what to type
|
||||
* has learned nothing.
|
||||
*
|
||||
* The command it names is `pinloop login` and there is no other
|
||||
* (specs/feature-browser-sign-in.md, agreed 2026-08-19). Signing in happens in a
|
||||
* browser, no password exists anywhere in this product, and the first time an
|
||||
* address signs in the account is created at that moment, so there is nothing a
|
||||
* separate sign-up step would do.
|
||||
*/
|
||||
export const SIGNED_OUT_SENTENCE =
|
||||
'Nobody is signed in on this machine. Run "pinloop login" and finish signing in in the browser; the first sign-in creates the account.';
|
||||
|
||||
/**
|
||||
* The notice that opens the guide when the agent's saved copy of the short
|
||||
* instruction file is older than the one this program ships.
|
||||
*
|
||||
* It tells the agent to tell the person before replacing the file. A file that
|
||||
* changes on somebody's machine without them being told is a surprise, and the
|
||||
* person may have edited that file themselves.
|
||||
*/
|
||||
export function staleSkillNotice(saved: number, newest: number): string {
|
||||
return `The copy of the Pinloop instruction file you have saved is out of date.
|
||||
The copy you are holding is version ${saved}, and this Pinloop ships version ${newest}.
|
||||
Tell the person their saved Pinloop instruction file is being replaced, then run "pinloop skill" and save the text it prints over the old file.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per command and per non-command part.
|
||||
*
|
||||
* The keys are the exact names the catalog spells. Nothing here is generated:
|
||||
* this is where a person writes what a command is for.
|
||||
*/
|
||||
export const GUIDE_TEXT: Record<string, string> = {
|
||||
// -------------------------------------------------------------------------
|
||||
// The parts that are not about any one command
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
overview: `Pinloop is a job search tool driven from a terminal. It holds a large collection
|
||||
of real job postings, it stores written documents describing the person you are
|
||||
working with, and it reads postings against those documents and says, for each
|
||||
posting, whether it suits them and why.
|
||||
|
||||
You are the one who types these commands. The person you are working with talks
|
||||
to you, and you run the commands and read the answers back to them.
|
||||
|
||||
Pinloop searches postings and judges them. It does not fill application forms in
|
||||
and it does not submit applications. If the person expects that, say so plainly
|
||||
rather than letting them find out later.
|
||||
|
||||
Everything below is generated from the commands this copy of Pinloop actually
|
||||
has, so it never describes a command that is not on this machine.`,
|
||||
|
||||
asking: `Things the person can say to you, when they do not know what to ask for. Read
|
||||
these back to them.
|
||||
|
||||
"Find me remote backend internships in Canada posted this week."
|
||||
"Store my resume and a paragraph about what I am looking for."
|
||||
"Go through these and tell me which ones are worth applying to, and why."
|
||||
"Keep a list of the ones you thought were strong."
|
||||
"Check every morning for new postings that match, and add them to that list."
|
||||
"Show me what you have on file about me."`,
|
||||
|
||||
json: `Every command that hands back rows will print machine-readable output instead of
|
||||
readable lines when you add --json. The shape is always the same: one JSON
|
||||
object on standard output, holding a "rows" list, plus a "cursor" where there is
|
||||
another page to ask for. Nothing else goes to standard output, so the output of
|
||||
one command can be piped straight into the next one.
|
||||
|
||||
Anything a person needs to read but the next command must not swallow goes to
|
||||
standard error instead: the report of what a search actually looked for, what a
|
||||
filter dropped, warnings, and refusals.
|
||||
|
||||
Four commands take no --json, because they hand back no rows: login, logout,
|
||||
profile put and profile delete. Three more take none because they print prose:
|
||||
welcome, skill and guide.`,
|
||||
|
||||
limits: `One page is at most 100 postings. When you want everything rather than one page,
|
||||
add --all and the command follows every page itself and prints the whole thing
|
||||
at once.
|
||||
|
||||
A set of posting ids named in one request may hold up to 10,000 of them. More
|
||||
than about 3,000 will not fit on a command line at all, because a shell cannot
|
||||
carry that much text as one argument.
|
||||
|
||||
--within takes its ids two ways, and the second one is the one to use with a
|
||||
pipe: either the ids themselves separated by commas, or a single dash, which
|
||||
means read them from the JSON piped in. So "pinloop tab get shortlist --all
|
||||
--json | pinloop list --within - --unjudged" reads a tab and keeps only what is
|
||||
still unjudged. Without the dash the command asks for an argument and stops.
|
||||
"pinloop fetch" is the other one that reads a piped set, and it needs no dash:
|
||||
give it ids or give it none and pipe the JSON in. Everything else that takes
|
||||
posting ids, "pinloop judgment delete" and "pinloop judgment get" among them,
|
||||
takes them as words on the command line only and reads nothing from a pipe.
|
||||
|
||||
One account may hold 100 tabs, each holding up to 1,000 postings; 10 schedules;
|
||||
and 10 watches. A routine may hold up to 20 steps.
|
||||
|
||||
One judge run judges up to 100 postings. One quick screening run takes up to
|
||||
1,000 postings and sends them to the model in groups of up to 100.
|
||||
|
||||
One command may pull at most ${PULL_CEILING.toLocaleString('en-US')} postings. A --all run over conditions
|
||||
matching more than that is refused before it fetches anything, in a sentence
|
||||
naming how many postings matched, so narrow the conditions or walk the pages
|
||||
yourself with --limit and --cursor.
|
||||
|
||||
An account may make up to ${RATE_LIMIT_REQUESTS} requests a minute. Past that the server refuses
|
||||
and says how long to wait; with --all the command waits it out for you rather
|
||||
than skipping a page.
|
||||
|
||||
Judging and searching by meaning are the two things an account may only do so
|
||||
much of in a month. Both counts reset at the start of each calendar month. No
|
||||
number for either one is written here on purpose: a number written into these
|
||||
instructions is compiled into the copy of Pinloop somebody installed weeks ago
|
||||
and would be wrong by the time they read it. Type "pinloop" on its own and it
|
||||
prints how much of each is left and the date they reset.
|
||||
|
||||
A judge run that asks for more postings than the month's judging usage limit
|
||||
covers judges as many as it covers and then stops, and it ends by saying how
|
||||
many postings it did not attempt and why, rather than failing. Once nothing is
|
||||
left, judge and a search with --semantic are refused in a sentence saying when
|
||||
the usage resets. Reading back a verdict this account already holds counts
|
||||
against nothing, and neither does --preview.`,
|
||||
|
||||
examples: `Five runs from start to finish, with the output Pinloop really printed.
|
||||
Each line that starts with a $ is what was typed; everything under such a line
|
||||
is what came back. Where output has been cut short, the text says so.
|
||||
|
||||
1. FIND POSTINGS, THEN READ ONE OF THEM WHOLE
|
||||
|
||||
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3
|
||||
|
||||
100/3,719 postings that matched came back. This search hands back the best 100 and no more.
|
||||
searched for postings carrying any one of these words: backend (backend, backends); intern (intern, interns, internship, internships)
|
||||
Software Engineer Intern, Fullstack - 12 Month Internship
|
||||
Bitpanda
|
||||
Vienna, Vienna, Austria
|
||||
2026-07-15
|
||||
https://job-boards.eu.greenhouse.io/bitpanda/jobs/4918465101
|
||||
|
||||
Internship - Search Backend Infra Engineer
|
||||
Perplexity
|
||||
Belgrade
|
||||
2026-07-01
|
||||
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
|
||||
|
||||
FP&A Intern (12-month Internship)
|
||||
feedzai
|
||||
Portugal
|
||||
2026-07-21
|
||||
https://careers.feedzai.com/job_description?gh_jid=8076564
|
||||
|
||||
The first two lines went to standard error rather than standard output, so a
|
||||
following command never swallows them. The third posting shows why judging
|
||||
exists: the words matched, the job is a finance internship.
|
||||
|
||||
A search result carries no description text. Take an id from --json and fetch
|
||||
that one posting to read all of it:
|
||||
|
||||
$ pinloop fetch 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
|
||||
|
||||
id: 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
|
||||
company_id: 2cbb8ed9-8789-4812-aec1-706e91e45bfe
|
||||
external_id: 4918465101
|
||||
title: Software Engineer Intern, Fullstack - 12 Month Internship
|
||||
company: Bitpanda
|
||||
locations: ["Vienna, Vienna, Austria","Barcelona"]
|
||||
workplace_type:
|
||||
employment_type: []
|
||||
description_text: Who we are
|
||||
We simplify wealth creation. Founded in 2014 in Vienna, Austria by Eric Demuth,
|
||||
|
||||
That run printed 53 lines; the description text carries on for another 40 and is
|
||||
cut off here. Description text is stored as the job board wrote it, so HTML
|
||||
pieces like appear in it. Two fields on this posting are empty, which is
|
||||
ordinary: not every posting says whether it is on-site, and not every posting
|
||||
says whether it is full time.
|
||||
|
||||
2. STORE WHAT PINLOOP JUDGES AGAINST, THEN JUDGE
|
||||
|
||||
$ pinloop profile put background < background.txt
|
||||
|
||||
stored your 'background' document (389 bytes)
|
||||
|
||||
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3 --json | pinloop judge
|
||||
|
||||
judging 3 postings with anthropic/claude-sonnet-5 via Anthropic
|
||||
verdict for 44894d98: no (1 of 3 done)
|
||||
verdict for 154a6760: weak (2 of 3 done)
|
||||
verdict for 4f188c1e: strong (3 of 3 done)
|
||||
3/3 postings you named were judged.
|
||||
1/4 profile documents were stored and sent to the model.
|
||||
no constraints document is stored, so nothing could rule a posting out on its own
|
||||
no preferences document is stored, so the model judged these postings without being told what this person wants
|
||||
no resume is stored, so the model read no resume
|
||||
judged 3 postings with anthropic/claude-sonnet-5
|
||||
weak 154a6760-e853-4a2f-bf25-5e1fc5d59fc7
|
||||
This is a 12-month fullstack internship requiring Vue.js frontend work plus Java/PHP backend, based in Vienna or Barcelona. Your background is backend-focused (Python/Postgres, Go) and you are explicitly looking for a summer 2027 backend or infrastructure internship, remote or in Europe.
|
||||
|
||||
Each verdict is followed by its reasoning, and the reasoning of the other two
|
||||
postings is cut off here. Read the four lines in the middle: this account had
|
||||
stored one document out of the four judging can use, and every missing one is
|
||||
named along with what the model therefore could not do. Storing a resume and a
|
||||
preferences document changes the verdicts, so store them before trusting a run.
|
||||
|
||||
3. SEARCH BY MEANING RATHER THAN BY WORDS
|
||||
|
||||
$ pinloop search --semantic --from-profile --top 5 --limit 3
|
||||
|
||||
mode: semantic
|
||||
model: voyageai/voyage-4 via OpenRouter
|
||||
query: profile documents background (updated 2026-08-22T00:16:55.910Z)
|
||||
searched with background
|
||||
not stored in this profile: preferences, resume
|
||||
closest 5
|
||||
59,124/59,124 postings in the searched set had a vector and were ranked
|
||||
Internship - Search Backend Infra Engineer
|
||||
Perplexity
|
||||
Belgrade
|
||||
2026-07-01
|
||||
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
|
||||
match 0.86
|
||||
|
||||
Software Engineer Intern
|
||||
Serval
|
||||
San Francisco
|
||||
2026-07-13
|
||||
https://jobs.ashbyhq.com/Serval/d7fb089c-db8a-4877-a5f3-73a09e67f54b
|
||||
match 0.86
|
||||
|
||||
Nothing was typed to say what to look for. --from-profile built the text out of
|
||||
the account's own stored documents, and again the answer named the documents
|
||||
that were not there to use. The third result is cut off here.
|
||||
|
||||
4. KEEP THE GOOD ONES IN A NAMED LIST
|
||||
|
||||
$ pinloop tab create strong-2027 --description "backend internships worth applying to"
|
||||
|
||||
created your 'strong-2027' tab
|
||||
|
||||
$ pinloop search backend intern --posted-after 2026-07-01 --limit 3 --json | pinloop judge --keep strong --json | pinloop tab add strong-2027
|
||||
|
||||
added 1 posting to your 'strong-2027' tab
|
||||
|
||||
$ pinloop tab get strong-2027
|
||||
|
||||
Internship - Search Backend Infra Engineer
|
||||
Perplexity
|
||||
Belgrade
|
||||
2026-07-01
|
||||
https://jobs.ashbyhq.com/Perplexity/be94e89b-89d5-4f2a-a58b-7929c8d97f92
|
||||
item af0c4b11-3b71-4314-96e5-11311bea2ffc
|
||||
|
||||
Three postings went into judge and one came out, because --keep strong hands on
|
||||
only the postings judged strong. That second run bought no judging at all: all
|
||||
three verdicts were already stored from the run above, so they were read back.
|
||||
The item id on the last line is what "pinloop tab remove" takes, and it names
|
||||
the posting's place in this tab rather than the posting.
|
||||
|
||||
5. SET IT RUNNING WITH NOBODY AT THE KEYBOARD
|
||||
|
||||
$ pinloop routine put nightly-backend --description "new backend internships, judged, strong ones kept" --steps '[{"command":"search","args":{"q":"backend intern","posted_after":"2026-07-01","limit":3}},{"command":"judge","args":{"keep":"strong"}},{"command":"tab add","args":{"name":"strong-2027"}}]'
|
||||
|
||||
stored your 'nightly-backend' routine (3 steps)
|
||||
|
||||
$ pinloop routine run nightly-backend
|
||||
|
||||
running your 'nightly-backend' routine, 3 steps, this can take a few minutes
|
||||
step 1 (search): 3 postings came out, none were handed in
|
||||
step 2 (judge): 1/3 postings handed in came out
|
||||
3 reused the judgment this account already had
|
||||
dropped 154a6760-e853-4a2f-bf25-5e1fc5d59fc7: judged weak, below strong
|
||||
dropped 44894d98-c3a8-4c05-a7e3-6abf48df6b6c: judged no, below strong
|
||||
step 3 (tab add): 1/1 postings handed in came out
|
||||
0/1 postings handed in were put in the tab
|
||||
|
||||
$ pinloop watch put new-backend --routine nightly-backend
|
||||
|
||||
stored your 'new-backend' watch: runs your 'nightly-backend' routine every hour over the postings that arrive after 2026-07-23T08:00:15.709Z, first run 2026-08-22T01:19:08.315Z
|
||||
|
||||
Every step says what it was handed and what it passed on, so a run that produced
|
||||
nothing tells you which step stopped it. Two lines here report work that was not
|
||||
done rather than hiding it: the judge step bought nothing because all three
|
||||
verdicts were already stored, and the tab step put in 0 of the 1 posting it was
|
||||
handed because that posting was already in the tab.
|
||||
|
||||
The watch has no cadence you set. It looks about once an hour, runs the routine
|
||||
over only the postings that arrived since it last looked, and does nothing at
|
||||
all in an hour when nothing arrived.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The account commands
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
login: `Signs in, and saves the pass this machine will use in a file only its owner can
|
||||
read. There is no password anywhere in Pinloop, and there is no separate command
|
||||
for creating an account: the first time an email address signs in, the account is
|
||||
created at that moment, and every later sign-in opens the same one.
|
||||
|
||||
Run it and it prints a web address and waits. The person opens that address in a
|
||||
browser, on any machine, and signs in there with Google, with GitHub, or with a
|
||||
6-digit code Pinloop emails them. The browser then hands the pass back to the
|
||||
waiting terminal by itself and the command prints one line saying who is signed
|
||||
in. Read the address out to the person; on a machine that has a browser the
|
||||
command also opens it.
|
||||
|
||||
When the browser is on a different machine from the terminal, which is what
|
||||
happens over SSH, the page shows a short code instead. Paste that code into the
|
||||
terminal that is waiting and the sign-in finishes there. The wait ends after ten
|
||||
minutes with one line saying so.
|
||||
|
||||
The pass is good for about an hour, and when it runs out the same file's renewal
|
||||
token gets a new one automatically, so a person only ever signs in by hand once
|
||||
per machine.`,
|
||||
|
||||
logout: `Deletes the saved login on this machine and prints one line saying it worked.
|
||||
It contacts no server: that file is the whole of what being signed in on a
|
||||
machine means, so deleting it is the whole of signing out. Run on a machine that
|
||||
has no saved login, it says so and reports success. It signs out this machine
|
||||
only; a pass saved on another machine is untouched.`,
|
||||
|
||||
billing: `Opens the page where the person sets up, changes or cancels their Pinloop
|
||||
subscription. "pinloop upgrade" is a second name for the same command and prints
|
||||
the same thing. Neither name takes any option.
|
||||
|
||||
It prints a web address and exits at once. It does not wait and it does not
|
||||
watch for a payment to go through, so there is nothing to keep open. On a
|
||||
machine that has a browser it opens the address as well as printing it. Read the
|
||||
address out to the person, because on a machine with no browser printing it is
|
||||
the whole of what happens.
|
||||
|
||||
Where the address leads depends on the account. An account with no subscription
|
||||
gets Pinloop's payment page, and the address carries a one-time code that stops
|
||||
working after ten minutes, so run the command again for a fresh one rather than
|
||||
reusing an old address. An account that already subscribes gets a one-time
|
||||
address into Stripe's own page, which is where a card is replaced, past charges
|
||||
are read, and the subscription is cancelled.
|
||||
|
||||
Nothing more is printed in the terminal afterwards, whatever the person does in
|
||||
the browser. A subscription that starts raises this account's monthly judging
|
||||
and meaning-searching usage limits from the next command onwards, and "pinloop"
|
||||
typed on its own prints what they have become. A cancelled subscription keeps
|
||||
the raised usage limits until the last day already paid for and then returns to
|
||||
the free ones. A renewal whose card fails changes nothing while the card is
|
||||
retried over about two weeks; if it is never paid the account returns to the
|
||||
free usage limits.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The five that take postings and hand postings on
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
search: `Searches the corpus for postings carrying the words you type. Whole words are
|
||||
matched, widened by plural rules and a small list of paired words, and the best
|
||||
matches come first by default. --match all requires every word rather than any
|
||||
one of them, --order newest puts the newest first, --in title looks at the title
|
||||
only, and --top says how many postings the server may consider before it stops.
|
||||
|
||||
--semantic searches by meaning rather than by words: it ranks postings by how
|
||||
close they are to a piece of text you give it, closest first, and prints a match
|
||||
strength from 0 to 1 for each one. With --semantic, --from-profile builds that
|
||||
text out of the account's own stored documents, --min-match sets a strength
|
||||
cutoff, and --preview prints the text that would be sent without contacting
|
||||
anybody.
|
||||
|
||||
Narrow the result with --country, --workplace, --employment, --posted-after,
|
||||
--company, --within (a set of posting ids to stay inside) and --unjudged (leave
|
||||
out everything this account has already judged).
|
||||
|
||||
Three of those take fixed values and it is worth knowing them exactly.
|
||||
--workplace is one of Remote Solely, Remote OK, Hybrid or On-site, and
|
||||
--employment is one of FULL_TIME, PART_TIME, CONTRACTOR, INTERN or TEMPORARY;
|
||||
anything else is refused in a sentence listing the real values, so a wrong guess
|
||||
is loud rather than silent. --country is different and quieter: it takes the
|
||||
country's full name as the posting carries it, like Canada or United States, and
|
||||
a two-letter code such as CA is not refused at all, it simply matches nothing and
|
||||
comes back empty. --country holds one country per run. Typing it twice does not
|
||||
search both; the last one silently wins, so run one country at a time. A posting
|
||||
may carry several countries, so a posting matched on Germany can well be listed
|
||||
under Portugal too.
|
||||
|
||||
--limit and --cursor page through the answer, --all follows every page at once,
|
||||
and --json prints rows a following command can read.
|
||||
|
||||
Searching by meaning is one of the two things an account may only do so much of
|
||||
in a month, and word searching is not counted at all. Asking for a later page of
|
||||
a meaning search you have already run counts against nothing; only the first
|
||||
page of each one is counted.
|
||||
|
||||
A search runs perfectly well with no words at all, as long as a condition
|
||||
narrows it. "pinloop search --company <employer id> --all" hands back every
|
||||
posting that employer has, and a wordless search has no scoring ceiling, so
|
||||
--top does not apply and it pages through everything the conditions allow. This
|
||||
is the way to take one employer's whole set, because list has no company
|
||||
condition.
|
||||
|
||||
Every answer also reports what the search actually looked for: which match mode
|
||||
it used, how many postings it was willing to score, and which spellings of each
|
||||
word it looked for. A wordless search says so in that line, naming what it
|
||||
narrowed by instead.`,
|
||||
|
||||
list: `Lists postings by exact conditions rather than by words, newest first. Nothing is
|
||||
matched or ranked: --employment, --country, --workplace and --posted-after pick
|
||||
postings out by the values stored on them, and they take exactly the values the
|
||||
searching part of these instructions lists: one of Remote Solely, Remote OK,
|
||||
Hybrid or On-site for --workplace, one of FULL_TIME, PART_TIME, CONTRACTOR,
|
||||
INTERN or TEMPORARY for --employment, and one country's full name, like Canada or
|
||||
United States, for --country. --within stays inside a set of posting
|
||||
ids, --unjudged leaves out everything this account has already judged, --limit
|
||||
and --cursor page through the answer, --all follows every page at once, and
|
||||
--json prints rows a following command can read.`,
|
||||
|
||||
fetch: `Prints the complete stored record behind each posting id, description text and
|
||||
all, which is far more than a search result carries. Give the ids on the command
|
||||
line, or pipe in the JSON another command printed and give none. A large set has
|
||||
to be piped in rather than typed, because ten thousand ids do not fit on a
|
||||
command line. --json prints one JSON object holding the records.`,
|
||||
|
||||
filter: `Keeps the postings piped into it that satisfy the conditions you give, and says
|
||||
on standard error what it dropped and why. It contacts no server at all: it works
|
||||
on rows another command already printed. --country, --workplace, --employment and
|
||||
--posted-after are the conditions, and they take the same values the searching
|
||||
part of these instructions lists. --json prints the survivors as rows a following
|
||||
command can read.
|
||||
|
||||
There is no region or continent condition of any kind. Keeping the postings in
|
||||
Europe means naming the countries yourself and running filter once per country,
|
||||
because --country holds one country at a time.`,
|
||||
|
||||
judge: `Reads postings against this account's stored documents and stores a verdict for
|
||||
each one, with the reasoning behind it. Give the posting ids, or pipe in the JSON
|
||||
another command printed. A verdict is one of four words: no, weak, fair or
|
||||
strong.
|
||||
|
||||
A verdict this account already holds for a posting is read back rather than
|
||||
judged again, and the answer says which ones were reused. --again judges every
|
||||
posting named again and writes the new verdict over the stored one. --keep hands
|
||||
on only the postings judged at or above a word, so a following command sees only
|
||||
those. --preview prints exactly what would be sent without sending it. --json
|
||||
prints the verdicts as rows a following command can read.
|
||||
|
||||
--quick screens rather than judges: it takes up to 1,000 postings, sends their
|
||||
plain stored facts without the job description text, in groups of up to 100, and
|
||||
stores a lighter verdict for each. A full judge run writes over a quick verdict
|
||||
and says so.
|
||||
|
||||
A quick screen sends the model far less about each posting, because it leaves
|
||||
the job description text out, and it uses far less of this account's month as
|
||||
well. One posting screened by --quick uses a fourteenth of what one posting
|
||||
judged in full uses, because a screening call carries up to 100 postings in a
|
||||
single request and sends no job description with any of them. That is what makes
|
||||
screening a large number of postings first, and then fully judging only the few
|
||||
that survive, cheaper for the month than fully judging everything. Nothing lets
|
||||
an account look at postings for nothing at all, though: narrowing the search
|
||||
before judging anything is still what saves the most usage.
|
||||
|
||||
Because a quick screen never sees the job description, anything stated only in
|
||||
the description, such as a security clearance or a degree requirement, cannot be
|
||||
caught by --quick. Only a full judge run reads that text.
|
||||
|
||||
What judge sends the model as its instructions is the document stored under the
|
||||
name judge-prompt, or the one Pinloop ships when nothing is stored there.
|
||||
--quick reads quick-judge-prompt the same way.
|
||||
|
||||
Judging is one of the two things an account may only do so much of in a month.
|
||||
A run bigger than what is left of the month's usage judges as much as that
|
||||
covers and ends by naming how many postings it did not attempt and why; the same postings are
|
||||
listed in the machine-readable output with that reason on each one. A verdict
|
||||
read back rather than bought counts against nothing. Type "pinloop" on its own
|
||||
to read how much is left.`,
|
||||
|
||||
companies: `Finds employers by the words in their name, the one with the most postings first.
|
||||
It hands back employers rather than postings, and each line carries the employer
|
||||
id that search's --company option takes. --limit and --cursor page through the
|
||||
answer, --all follows every page at once, and --json prints one JSON object
|
||||
holding the rows.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The account's own documents
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
'profile put': `Stores one document under a name, replacing whatever that name held before. The
|
||||
text is read from standard input, or from a file with --file. Seven names mean
|
||||
something to Pinloop itself: resume, background, preferences, constraints,
|
||||
application-instructions, judge-prompt and quick-judge-prompt. Any other name of
|
||||
lowercase letters, digits and dashes is yours to use.
|
||||
|
||||
The resume is a real PDF file rather than text: Pinloop checks that it is a PDF
|
||||
before storing any of it, keeps the bytes in a private store, and reads the words
|
||||
out of it once so a meaning-based search can use them. --default, on judge-prompt
|
||||
or quick-judge-prompt only, removes the stored text so runs go back to the
|
||||
instructions Pinloop ships.`,
|
||||
|
||||
'profile get': `Prints one stored document. With no name and --all, prints every document, text
|
||||
included, as one JSON object. --text prints the words read out of a stored file
|
||||
rather than the file itself, which is how you read the resume as text. Without
|
||||
--text the resume comes back as the PDF bytes, so send it to a file rather than
|
||||
to the terminal.`,
|
||||
|
||||
'profile list': `Shows every document this account has stored, with each one's kind, size and
|
||||
last-updated date. It never prints the text of a document. --json prints one JSON
|
||||
object holding the documents.`,
|
||||
|
||||
'profile delete': `Removes one document, or every document this account has stored with --all. When
|
||||
the resume is removed, the stored file's bytes are destroyed with the row.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The account's own named lists of postings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
'tab create': `Makes one named list of postings. The name is lowercase letters, digits and
|
||||
dashes. --description says what the tab is for. An account may hold 100 tabs.`,
|
||||
|
||||
'tab list': `Shows every tab this account has, with how many postings each one holds. --limit
|
||||
and --cursor page through the answer, --all follows every page at once, and
|
||||
--json prints one JSON object holding the tabs.`,
|
||||
|
||||
'tab get': `Shows the postings in one tab as they stand in the corpus right now, rather than
|
||||
as they were when they were added. A posting that has since been taken down comes
|
||||
back marked as gone rather than quietly disappearing. --sort orders by when a
|
||||
posting was added or by the date the job was posted, --order chooses newest or
|
||||
oldest first, --limit and --cursor page through the answer, --all follows every
|
||||
page, and --json prints rows a following command can read.`,
|
||||
|
||||
'tab add': `Puts postings into one tab, either by id on the command line or from the JSON
|
||||
piped into it, which is how the result of a search or a judge run goes straight
|
||||
into a list. A tab holds up to 1,000 postings, and a posting already in the tab
|
||||
is not added twice. This is the one command that changes something and can still
|
||||
be a step inside a routine, so a routine running overnight can fill a tab.`,
|
||||
|
||||
'tab remove': `Takes postings out of one tab, named by the item ids that reading the tab prints.
|
||||
Nothing about the postings themselves changes.`,
|
||||
|
||||
'tab rename': `Gives one tab a different name, keeping everything in it.`,
|
||||
|
||||
'tab delete': `Removes one tab and everything in it. The postings themselves are untouched; only
|
||||
the list goes.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The stored verdicts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
'judgment list': `Shows every verdict this account has stored, newest first, each line ending with
|
||||
who made it. --verdict shows only the verdicts carrying one word, --judged-by
|
||||
narrows to one source and takes one of three words: "user" for a verdict a person
|
||||
stored by hand, "pinloop" for one a full judge run produced, and "pinloop-quick"
|
||||
for one a quick screening run produced. Nothing records whether a judge run was
|
||||
typed at a keyboard or fired by a schedule or a watch, so both land under
|
||||
"pinloop" and cannot be told apart. --reasoning prints the reasoning of each one
|
||||
underneath. --limit,
|
||||
--cursor and --all page through the answer, and --json prints one JSON object
|
||||
holding the verdicts.`,
|
||||
|
||||
'judgment get': `Shows one stored verdict whole, the reasoning included, for the posting id you
|
||||
name.`,
|
||||
|
||||
'judgment delete': `Removes the stored verdicts for the postings you name, so judging them again
|
||||
produces a fresh verdict.`,
|
||||
|
||||
'judgment put': `Stores a verdict a person made outside Pinloop, so Pinloop counts that posting
|
||||
judged and leaves it out of later runs. Give one posting id with --verdict and
|
||||
--reasoning, or pipe in up to 1,000 rows as JSON. Every verdict stored this way
|
||||
is marked as made by a person, and storing one calls no model and counts against
|
||||
nothing. A bad row is dropped and named rather than stopping the good ones.
|
||||
|
||||
A verdict this account already holds for that posting is written over, whoever
|
||||
made it, and the answer lists what was replaced and who had made it. There is no
|
||||
need to delete the old verdict first.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The stored pipelines
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
'routine put': `Stores a pipeline of commands under a name, replacing whatever that name held
|
||||
before. The steps are a JSON list, given with --steps or piped in, of up to 20
|
||||
steps. A step may name only a command that takes postings and hands postings on:
|
||||
search, list, fetch, filter, judge and tab add. --description says what the
|
||||
routine is for.
|
||||
|
||||
A step's arguments are spelled the way the machine-readable output spells them,
|
||||
not the way the option is typed at a terminal: the words to search for are "q"
|
||||
rather than the bare words, and a name of more than one word carries an
|
||||
underscore, as in "posted_after". An argument a command does not take is refused
|
||||
when the routine is stored, in a sentence naming the step, the argument and
|
||||
every argument that command does take, so a wrong name is found once rather than
|
||||
every night.`,
|
||||
|
||||
'routine get': `Shows one stored routine and the steps it holds, in order.`,
|
||||
|
||||
'routine list': `Shows every routine this account has stored.`,
|
||||
|
||||
'routine delete': `Removes one routine. A routine a schedule or a watch fires is not removed; the
|
||||
refusal names what is still pointing at it.`,
|
||||
|
||||
'routine run': `Runs one routine's steps in order on the server and prints what the last step
|
||||
produced. --within gives the first step a set of posting ids to work inside,
|
||||
either as ids or as JSON piped in. --json prints rows a following command can
|
||||
read. Every step's outcome is kept, so a run that stopped part way says which
|
||||
step stopped it.`,
|
||||
|
||||
'routine results': `Shows what the most recent run of one routine produced, read fresh from the corpus
|
||||
rather than from a copy taken at the time. --json prints rows a following command
|
||||
can read.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Running without anybody at the keyboard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
'schedule put': `Stores a schedule: one routine, run every so many hours with nobody at the
|
||||
keyboard. --routine names the routine and --every-hours is a whole number of
|
||||
hours from 1 to 168, both required. --first-due-at fixes when the first run
|
||||
happens, and the time of day it lands on is the time of day every later run lands
|
||||
on. An account may hold 10 schedules.`,
|
||||
|
||||
'schedule get': `Shows one schedule, when it next runs, and how its last run went.`,
|
||||
|
||||
'schedule list': `Shows every schedule this account has stored, with the next run and the outcome of
|
||||
the last one.`,
|
||||
|
||||
'schedule delete': `Removes one schedule, so the routine it ran stops running by itself. The routine
|
||||
itself stays.`,
|
||||
|
||||
'watch put': `Stores a watch: one routine, run over only the postings that arrived in the corpus
|
||||
since this watch last looked. --routine names the routine, whose first step has to
|
||||
be a search or a list, and is required. A watch has no cadence of its own; it
|
||||
looks about once an hour. Nothing arriving means nothing runs. An account may hold
|
||||
10 watches. A routine whose last step adds to a tab is what fills a list of new
|
||||
finds overnight.`,
|
||||
|
||||
'watch get': `Shows one watch, when it next looks, and how its last firing went, including how
|
||||
many postings had arrived and how many of them matched.`,
|
||||
|
||||
'watch list': `Shows every watch this account has stored, with the outcome of the last firing of
|
||||
each one.`,
|
||||
|
||||
'watch delete': `Removes one watch, so the routine it ran stops running by itself. The routine
|
||||
itself stays.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reaching the person who builds Pinloop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
message: `Sends a message to Andrew (who builds Pinloop) and prints the conversation between
|
||||
this account and him. Typed with words after it, it sends those words. Typed on
|
||||
its own, it prints the whole back-and-forth, oldest message first. --json prints
|
||||
that same conversation as rows you can read.
|
||||
|
||||
Use it when something goes wrong: a command failed, an answer looks wrong, the
|
||||
corpus is missing something the person expected, or Pinloop does not do something
|
||||
they need. Andrew reads these himself and answers.
|
||||
|
||||
Never send anything the person has not read. Write the draft yourself, show them
|
||||
the exact words you are about to send, wait for them to say yes, and only then
|
||||
run the command. If you are writing about something that broke, paste the command
|
||||
that failed and the error text it printed into the draft, so the person reads
|
||||
both before they say yes. Pinloop attaches nothing to the words on its own, which
|
||||
means an error line holding the path to their resume file goes only if they read
|
||||
it and left it in.
|
||||
|
||||
One message is at most ${inFigures(MAX_MESSAGE_CHARS)} characters, which is long enough for a real
|
||||
report with a whole error dump pasted into it. One account may send ${inFigures(MESSAGES_PER_DAY)}
|
||||
messages a day. Going over either one is refused in a sentence saying what the
|
||||
limit is, and the second of the two also says when the limit lifts.
|
||||
|
||||
When a line appears telling you an answer from Andrew is waiting, run this
|
||||
command and give the person his answer word for word. Once the conversation has
|
||||
been printed, the line stops appearing.`,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The three that print text
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
welcome: `Prints the text that introduces Pinloop to you: what Pinloop is, the short
|
||||
instruction file to save into your own skills folder, and the first steps to work
|
||||
through with the person, in order. It prints the same text whether or not
|
||||
anybody is signed in, and it contacts no server. It is the first thing to run on
|
||||
a machine where Pinloop has just been installed.`,
|
||||
|
||||
skill: `Prints the short instruction file, the few sentences you save in your own skills
|
||||
folder and read at the start of a conversation. It writes no file anywhere:
|
||||
every agent tool looks in a different folder, and you are the only one who knows
|
||||
where yours looks. The text carries a version number, and it ends by telling you
|
||||
to come back and read these full instructions.
|
||||
|
||||
Tell the person before you write this text over a file they already have. The
|
||||
file sits in their own skills folder and they may have edited it themselves, so
|
||||
replacing it without saying so takes away something of theirs without asking.
|
||||
Saving it for the first time needs no such warning. To find out whether the copy
|
||||
you hold is behind, run "pinloop guide --skill" with the version number written
|
||||
in your saved copy; the instructions then open with a notice when yours is
|
||||
older, and print nothing extra when it is current.`,
|
||||
|
||||
guide: `Prints these instructions. With no part named it prints all of them; with the
|
||||
name of a command after it, like "pinloop guide judge", it prints that command's
|
||||
part only. It works whether or not anybody is signed in. --skill takes the
|
||||
version number of the instruction file you have saved, and the output opens with
|
||||
a notice when your saved copy is older than the one this Pinloop ships.`,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Builds the instructions `pinloop guide` prints.
|
||||
*
|
||||
* Nothing here is typed out by hand as one long document. The builder walks the
|
||||
* real command tree the installed program builds, and for each command it finds
|
||||
* it looks up the paragraph written for that one command in
|
||||
* src/shared/guide-text.ts. Two things follow from that. The instructions always
|
||||
* describe the copy of Pinloop the person actually installed, never a newer or
|
||||
* older one. And a command added to the program with no paragraph written for it
|
||||
* stops the build with that command named, which src/shared/guide.test.ts turns
|
||||
* into a failing test, so nobody ships an undocumented command by accident.
|
||||
*
|
||||
* A node in the command tree with children under it — `profile`, `tab`,
|
||||
* `routine` — groups commands rather than being one, so the walk goes into it
|
||||
* rather than counting it. The words of a command are joined by single spaces,
|
||||
* which is exactly how src/core/catalog.ts spells them and how the written text
|
||||
* is keyed: `search`, `profile put`, `tab add`.
|
||||
*
|
||||
* Three things change what comes out:
|
||||
*
|
||||
* - A part name. With none, the whole thing prints. With one, only that part
|
||||
* prints, so `pinloop guide judge` is the judging part and nothing else. A
|
||||
* name that matches neither a command nor one of the fixed non-command parts
|
||||
* is refused, and the refusal names the word that was typed and lists every
|
||||
* name that would have worked.
|
||||
* - The version number of the instruction file the agent has saved. When it is
|
||||
* older than the newest one, the output opens with a notice saying so. When
|
||||
* it is the newest, or higher than any that has ever existed, no notice
|
||||
* appears, because a notice that shows when nothing is out of date teaches an
|
||||
* agent to ignore every notice.
|
||||
* - Whether anybody is signed in. When nobody is, the last thing printed is the
|
||||
* sentence naming the command that creates an account, set apart by a blank
|
||||
* line, because somebody who has just run the guide is looking at the bottom
|
||||
* of their screen rather than the top.
|
||||
*
|
||||
* The guide carries no welcome text of any kind. `pinloop welcome` is its own
|
||||
* command and prints the same text for everybody, so nothing here has to work
|
||||
* out whether an account is new.
|
||||
*/
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { GUIDE_TEXT, SIGNED_OUT_SENTENCE, staleSkillNotice } from './guide-text.ts';
|
||||
|
||||
/**
|
||||
* The part names a person may ask for that are not commands.
|
||||
*
|
||||
* This is the one short list of part names kept by hand. Every other part name
|
||||
* comes from the command tree itself, so nobody anywhere keeps a second list of
|
||||
* command names.
|
||||
*/
|
||||
export const NON_COMMAND_PARTS: readonly string[] = Object.freeze([
|
||||
'overview',
|
||||
'asking',
|
||||
'json',
|
||||
'limits',
|
||||
'examples',
|
||||
]);
|
||||
|
||||
/** What a caller hands the builder. */
|
||||
export type GuideOptions = {
|
||||
/** The real command tree, as buildProgram() hands it back. */
|
||||
program: Command;
|
||||
/** One part name, or absent for the whole thing. */
|
||||
part?: string;
|
||||
/** Whether this machine holds a saved login. */
|
||||
signedIn: boolean;
|
||||
/** The newest version of the instruction file, as the server answered. */
|
||||
newestSkillVersion: number;
|
||||
/** The version the agent typed after --skill, or absent when it typed none. */
|
||||
savedSkillVersion?: number;
|
||||
/**
|
||||
* The written text to build from. Absent means the text this program ships.
|
||||
* It is an argument so a test can hand in a copy with one command's paragraph
|
||||
* removed and watch the build refuse.
|
||||
*/
|
||||
text?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every command a person can type, with the words joined by single spaces.
|
||||
*
|
||||
* A node with children under it groups commands rather than being one, so the
|
||||
* walk goes into it and never counts it.
|
||||
*/
|
||||
export function commandsIn(program: Command): string[] {
|
||||
const found: string[] = [];
|
||||
const walk = (node: Command, words: string[]): void => {
|
||||
for (const child of node.commands) {
|
||||
const path = [...words, child.name()];
|
||||
if (child.commands.length === 0) found.push(path.join(' '));
|
||||
else walk(child, path);
|
||||
}
|
||||
};
|
||||
walk(program, []);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One part, printed under a heading a person can find by eye. */
|
||||
function section(name: string, text: Record<string, string>, isCommand: boolean): string {
|
||||
const heading = isCommand ? `pinloop ${name}` : name;
|
||||
const underline = '-'.repeat(heading.length);
|
||||
return `${heading}\n${underline}\n\n${text[name]!.trim()}`;
|
||||
}
|
||||
|
||||
/** The whole of the instructions, or one part of them. */
|
||||
export function buildGuide(options: GuideOptions): string {
|
||||
const text = options.text ?? GUIDE_TEXT;
|
||||
const commands = commandsIn(options.program);
|
||||
|
||||
// The completeness check comes first, so a command with no paragraph written
|
||||
// for it stops the build whatever else was asked for.
|
||||
const undocumented = [...NON_COMMAND_PARTS, ...commands].filter((name) => {
|
||||
const written = text[name];
|
||||
return typeof written !== 'string' || written.trim() === '';
|
||||
});
|
||||
if (undocumented.length > 0) {
|
||||
throw new Error(
|
||||
`the instructions have nothing written for ${undocumented.join(', ')}. ` +
|
||||
`Every command a person can type needs a paragraph in src/shared/guide-text.ts, ` +
|
||||
`keyed by the name the catalog spells.`,
|
||||
);
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
|
||||
if (
|
||||
options.savedSkillVersion !== undefined &&
|
||||
options.savedSkillVersion < options.newestSkillVersion
|
||||
) {
|
||||
blocks.push(staleSkillNotice(options.savedSkillVersion, options.newestSkillVersion));
|
||||
}
|
||||
|
||||
if (options.part !== undefined && options.part !== '') {
|
||||
const wanted = options.part;
|
||||
const isCommand = commands.includes(wanted);
|
||||
if (!isCommand && !NON_COMMAND_PARTS.includes(wanted)) {
|
||||
throw new Error(
|
||||
`there is no part of these instructions called "${wanted}". ` +
|
||||
`The parts you can ask for are: ${[...NON_COMMAND_PARTS, ...commands].join(', ')}.`,
|
||||
);
|
||||
}
|
||||
blocks.push(section(wanted, text, isCommand));
|
||||
} else {
|
||||
for (const name of NON_COMMAND_PARTS) blocks.push(section(name, text, false));
|
||||
blocks.push('The commands, one at a time.');
|
||||
for (const name of commands) blocks.push(section(name, text, true));
|
||||
}
|
||||
|
||||
if (!options.signedIn) blocks.push(SIGNED_OUT_SENTENCE.trim());
|
||||
|
||||
return `${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The numbers both the server and the installed `pinloop` command have to
|
||||
* state, kept in one place so neither carries its own copy of them.
|
||||
*
|
||||
* Every other limit in this product lives in src/server/limits.ts, which is
|
||||
* where they are read from and where they are documented. These live here
|
||||
* instead, and src/server/limits.ts exports them again under the same names, so
|
||||
* that every file which already reads them keeps reading them from the same
|
||||
* place.
|
||||
*
|
||||
* They are here because the block of instructions the installed `pinloop`
|
||||
* command prints for a coding agent states them out loud
|
||||
* (src/shared/guide-text.ts), and the installed command may only be built out
|
||||
* of src/cli/ and src/shared/ — no database driver, no web framework, none of
|
||||
* the server (src/cli/package-boundary.test.ts). Each number below is printed
|
||||
* by those instructions as well as enforced by the server. A number typed into
|
||||
* the instructions a second time would go on saying 60 after the server had
|
||||
* moved to 120, so the instructions read the number instead of repeating it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The most messages one account may send inside one day
|
||||
* (specs/feature-messages.md, AGREED 2026-08-20).
|
||||
*
|
||||
* Twenty is far more than a person writes in a day, and low enough that a
|
||||
* coding agent stuck repeating itself overnight cannot write thousands of rows
|
||||
* into the database before anybody is awake to see it. The count is of rows
|
||||
* written in the last 24 hours, not a tally held in the server's memory, because
|
||||
* a tally in memory is emptied by every deploy and a per-day limit that forgets
|
||||
* itself on every deploy is not a per-day limit.
|
||||
*
|
||||
* The one account this is never applied to is the account the server's owner
|
||||
* setting names, which is Andrew's: he answers many people in one sitting.
|
||||
*/
|
||||
export const MESSAGES_PER_DAY = 20;
|
||||
|
||||
/**
|
||||
* The most characters one message may carry
|
||||
* (specs/feature-messages.md, AGREED 2026-08-20).
|
||||
*
|
||||
* Ten thousand is long enough for a real report with the command that failed
|
||||
* and its whole error dump pasted into it, which is exactly what the
|
||||
* instructions tell a coding agent to put in the draft.
|
||||
*/
|
||||
export const MAX_MESSAGE_CHARS = 10_000;
|
||||
|
||||
/**
|
||||
* How many requests one account may make inside RATE_LIMIT_WINDOW_MS before the
|
||||
* server starts answering "try again later" (HTTP 429).
|
||||
*
|
||||
* Raised from 60 to 120 on 2026-08-20 (specs/feature-pull-ceiling.md, section
|
||||
* 3). Sixty was a guess made when nothing else in Pinloop bounded anything, and
|
||||
* nobody had ever measured it. Twenty full pages of `pinloop list` sent to the
|
||||
* live server at the same instant all came back inside 1.22 seconds, which is
|
||||
* about a thousand requests a minute on the one machine Pinloop runs on. What
|
||||
* protects that machine under real load is POOL_WAIT_MS, not this number.
|
||||
*/
|
||||
export const RATE_LIMIT_REQUESTS = 120;
|
||||
|
||||
/**
|
||||
* The most postings one typed command may pull, however many pages it follows.
|
||||
*
|
||||
* A command that would pull more than this is refused before a single page is
|
||||
* fetched, in the one sentence pullCeilingRefusal writes
|
||||
* (specs/feature-pull-ceiling.md, section 2). It covers the two commands that
|
||||
* page through the bought corpus, `pinloop list` and `pinloop search`, and it
|
||||
* covers a whole `--all` run rather than one page of one.
|
||||
*
|
||||
* Why five thousand: a page holds at most MAX_PAGE_SIZE postings, which is 100,
|
||||
* so 5,000 postings is 50 requests. An account may make RATE_LIMIT_REQUESTS
|
||||
* requests a minute, which is 120, and the rule this feature comes from is that
|
||||
* no single typed command may spend more than half of a minute's allowance.
|
||||
* Fifty is well under half of a hundred and twenty, and a run of 5,000 postings
|
||||
* finishes in a few seconds without ever being told to wait.
|
||||
*/
|
||||
export const PULL_CEILING = 5_000;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* How many calls to the model provider Pinloop keeps open at the same time.
|
||||
*
|
||||
* This number lives in src/shared/ rather than beside the code that makes the
|
||||
* calls because two very different pieces of the product need the same figure
|
||||
* and neither may import the other. The server uses it to decide how many
|
||||
* postings it sends to the model at once. The `pinloop` command prints it in the
|
||||
* group of lines it redraws while a judge run goes — "judging 12 postings, 4 at
|
||||
* a time" — and the command may import nothing from the server
|
||||
* (src/cli/package-boundary.test.ts walks its imports and fails on any file
|
||||
* outside src/cli/ and src/shared/). Written down twice, the two copies would
|
||||
* drift and the line on the screen would quietly start lying about what the
|
||||
* server is doing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How many provider calls may be in flight at once.
|
||||
*
|
||||
* One at a time would make a full hundred-posting run take ten minutes or more,
|
||||
* which is the run the cap allows; a large number is a burst the provider
|
||||
* throttles. Four keeps a full run inside the low minutes the design promises
|
||||
* and stays well under any published rate.
|
||||
*/
|
||||
export const MAX_IN_FLIGHT_REQUESTS = 4;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* What the server says to a caller while a judge run is still going, and the
|
||||
* one content type it says it under.
|
||||
*
|
||||
* A caller that wants to watch a run put the header `Accept:
|
||||
* application/x-ndjson` on the request it already makes. The server then
|
||||
* answers 200 under that same content type and writes one complete JSON object
|
||||
* per line, each line ended with a newline, as things happen. Every line but the
|
||||
* last is one of the events below. The last line is not an event at all: it is
|
||||
* the complete answer the same request without that header returns, unchanged.
|
||||
*
|
||||
* This file lives in src/shared/ because both halves need it. The server writes
|
||||
* these objects and the `pinloop` command reads them, and the command may import
|
||||
* nothing from the server (src/cli/package-boundary.test.ts walks its imports and
|
||||
* fails on any file outside src/cli/ and src/shared/).
|
||||
*/
|
||||
|
||||
/** The content type progress is asked for and answered under: JSON, one object per line. */
|
||||
export const PROGRESS_CONTENT_TYPE = 'application/x-ndjson';
|
||||
|
||||
/**
|
||||
* One thing that happened while a run was going.
|
||||
*
|
||||
* A run makes one model call per posting, or one call per hundred postings when
|
||||
* it is a quick screen, so `call` and `of` count one call off against the whole
|
||||
* run: call 3 of 12. The model and the serving company on a call-started event
|
||||
* are read off the first piece that call's provider sent back, never out of a
|
||||
* constant, because the model that answers is not always the model that was
|
||||
* asked for.
|
||||
*/
|
||||
export type ProgressEvent =
|
||||
/** A call's first piece has arrived, so the call is really under way. */
|
||||
| {
|
||||
kind: 'call-started';
|
||||
call: number;
|
||||
of: number;
|
||||
/** How many postings this one call carries: one, or up to a hundred. */
|
||||
postings: number;
|
||||
/**
|
||||
* The posting this call is about. A quick screen packs up to a hundred
|
||||
* postings into one call, and this then holds the first of them, in the
|
||||
* order the call carried them.
|
||||
*
|
||||
* It is here because the group of lines the `pinloop` command redraws
|
||||
* while a run goes names each open call by the first eight characters of a
|
||||
* posting's id, and until the call finishes there is nothing else on the
|
||||
* call to name it by.
|
||||
*/
|
||||
id: string;
|
||||
/** The model that is really answering, as the provider named it. */
|
||||
model: string;
|
||||
/** The company serving that model, as the provider named it. */
|
||||
provider: string;
|
||||
}
|
||||
/** A piece of the model's thinking, in the words it arrived in. */
|
||||
| { kind: 'thinking'; call: number; text: string }
|
||||
/** How many characters of the answer itself have arrived so far. */
|
||||
| { kind: 'answer'; call: number; chars: number }
|
||||
/** One posting's verdict, the moment the call carrying it finished. */
|
||||
| { kind: 'verdict'; call: number; id: string; verdict: string }
|
||||
/** A call that could not be finished, with the reason in the words it came in. */
|
||||
| { kind: 'call-failed'; call: number; of: number; reason: string }
|
||||
/**
|
||||
* A step of a stored command sequence has begun. A run of a stored sequence
|
||||
* says nothing else while it goes: the answer still arrives once, at the end.
|
||||
* `command` is the command that step names — search, list, filter, fetch, tab
|
||||
* add or judge — so a person waiting reads what is happening rather than only
|
||||
* how far along it is.
|
||||
*/
|
||||
| { kind: 'step-started'; step: number; of: number; command: string }
|
||||
/**
|
||||
* The run itself failed after progress had already started. It is only ever
|
||||
* the last line, and it is how a failure is reported at all once the status
|
||||
* number at the top of the answer has gone out as 200.
|
||||
*/
|
||||
| { kind: 'error'; message: string };
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* The reserved document names, the size caps, and the exact words every refusal
|
||||
* carries.
|
||||
*
|
||||
* A profile is a set of named documents. Most names are the user's own
|
||||
* invention and mean nothing to Pinloop beyond "the user keeps this here". A
|
||||
* short list of names is different: the product itself decides what those names
|
||||
* are for, so `constraints` is always the document judge reads for work
|
||||
* authorization and `resume` is always the PDF the fill engine attaches. That
|
||||
* list is the registry below, and it is data rather than scattered checks so
|
||||
* that adding a name is one line in one file.
|
||||
*
|
||||
* Each reserved name has one fixed kind. `resume` holds an uploaded file; the
|
||||
* other five hold text. A name that is not in the registry is a legal text
|
||||
* document, and nothing in the product reads it by itself.
|
||||
*
|
||||
* The two caps and the four refusal sentences live here too, because the tests
|
||||
* that pin them and the code that enforces them have to read the same numbers
|
||||
* and the same words instead of each keeping a copy.
|
||||
*/
|
||||
|
||||
/** What a document holds: characters in the row, or bytes in file storage. */
|
||||
export type DocumentKind = 'text' | 'file';
|
||||
|
||||
/**
|
||||
* The names the product itself gives meaning to, each with the one kind it may
|
||||
* hold. Growing this list is a spec change (specs/feature-profile.md), never a
|
||||
* code default.
|
||||
*/
|
||||
export const RESERVED_NAMES: Readonly<Record<string, DocumentKind>> = Object.freeze({
|
||||
resume: 'file',
|
||||
'application-instructions': 'text',
|
||||
constraints: 'text',
|
||||
background: 'text',
|
||||
preferences: 'text',
|
||||
'judge-prompt': 'text',
|
||||
'quick-judge-prompt': 'text',
|
||||
});
|
||||
|
||||
/**
|
||||
* The name whose stored text becomes the instructions judge sends the model
|
||||
* (specs/feature-judge-prompt.md, AGREED 2026-08-15).
|
||||
*
|
||||
* What storing a document under this name does, exactly:
|
||||
*
|
||||
* - Its text is sent to the model in place of DEFAULT_JUDGE_PROMPT below,
|
||||
* whole. Nothing is added to it and nothing is taken off it.
|
||||
* - It is never sent as one of the labelled profile documents. The model is
|
||||
* handed the posting and the person's other documents, and this text once,
|
||||
* as the instructions.
|
||||
* - A document stored under this name whose text is empty once the spaces and
|
||||
* line breaks are taken off the ends counts as no document at all: judge
|
||||
* sends the shipped default instead. Sending the model no instructions would
|
||||
* still get an answer back, and nobody would ever find out.
|
||||
* - It is not one of the four documents judge needs. An account that has
|
||||
* stored this and nothing else is refused before any model is called,
|
||||
* because there is nothing to judge a posting against.
|
||||
*/
|
||||
export const JUDGE_PROMPT_NAME = 'judge-prompt';
|
||||
|
||||
/**
|
||||
* The instructions judge sends the model when the account has stored none of
|
||||
* its own. Andrew approved this text on 2026-08-15, word for word.
|
||||
*
|
||||
* Changing it is meant to be a deliberate act rather than a tidy-up: this text
|
||||
* decides every verdict the product produces. A copy of it sits in
|
||||
* src/server/judge-prompt.test.ts, so anyone who edits it here has to edit that
|
||||
* copy as well and the change shows up in the diff.
|
||||
*/
|
||||
export const DEFAULT_JUDGE_PROMPT =
|
||||
'You are judging one job posting against one person, for a product called Pinloop.\n' +
|
||||
'\n' +
|
||||
'You are given the complete stored posting, then every document this person keeps in ' +
|
||||
'their profile, each labelled with the name it is stored under. Four of those names ' +
|
||||
'mean something fixed:\n' +
|
||||
'\n' +
|
||||
'- constraints: rules this person cannot break, such as work authorization, where they ' +
|
||||
'can be, and when they are free. If the posting breaks any rule written there, the ' +
|
||||
'verdict is `no`, whatever else is true, and your reasoning must name the rule it breaks.\n' +
|
||||
'- background: what this person has done and can do.\n' +
|
||||
'- preferences: what this person wants and does not want.\n' +
|
||||
'- resume: attached as a PDF file with this message, when they have stored one. Read it ' +
|
||||
'as part of their profile.\n' +
|
||||
'\n' +
|
||||
'Judge the posting against the background and the preferences together. Any other ' +
|
||||
"document in the profile is part of this person's profile too: read it and take it into " +
|
||||
'account.\n' +
|
||||
'\n' +
|
||||
'Give one verdict, exactly one of these four words:\n' +
|
||||
'\n' +
|
||||
'- `no`: a constraint is broken, or the posting has nothing to do with this person.\n' +
|
||||
'- `weak`: they could apply, but the posting asks mostly for things they do not have or ' +
|
||||
'do not want.\n' +
|
||||
'- `fair`: a real match on the main things, with clear gaps.\n' +
|
||||
'- `strong`: this person is what the posting is asking for, and the posting is what they ' +
|
||||
'want.\n' +
|
||||
'\n' +
|
||||
'Then give your reasoning, in plain sentences, written for this person to read. Say what ' +
|
||||
'drove the verdict. Grade honestly: this person wants a judgement they can act on, not ' +
|
||||
'encouragement.\n' +
|
||||
'\n' +
|
||||
'Answer only in the shape you were given.\n';
|
||||
|
||||
/**
|
||||
* The name whose stored text becomes the instructions a quick screening run
|
||||
* sends the model (specs/feature-quick-judge.md, the Architecture section, call
|
||||
* 4, approved by Andrew on 2026-08-16).
|
||||
*
|
||||
* It behaves exactly the way JUDGE_PROMPT_NAME above behaves, and applies to
|
||||
* quick screening runs alone: its text replaces DEFAULT_QUICK_JUDGE_PROMPT
|
||||
* whole, a document holding nothing but spaces counts as none, it is never sent
|
||||
* as one of the labelled profile documents, and it is not one of the four
|
||||
* documents a run needs before it will judge anything. An ordinary judge run
|
||||
* never reads it, and a quick screening run never reads `judge-prompt`.
|
||||
*/
|
||||
export const QUICK_JUDGE_PROMPT_NAME = 'quick-judge-prompt';
|
||||
|
||||
/**
|
||||
* The instructions a quick screening run sends the model when the account has
|
||||
* stored none of its own.
|
||||
*
|
||||
* It is not the ordinary judge prompt and cannot be: a quick screening run
|
||||
* sends up to a hundred postings in one request and sends none of their job
|
||||
* description text, so the model is being asked for something weaker than a
|
||||
* judgement and has to be told so in as many words. The four verdict words are
|
||||
* the same four, because `--keep fair` has to mean the same thing on both kinds
|
||||
* of run.
|
||||
*/
|
||||
export const DEFAULT_QUICK_JUDGE_PROMPT =
|
||||
'You are screening job postings against one person, for a product called Pinloop.\n' +
|
||||
'\n' +
|
||||
'You get up to a hundred postings in this one message, and for each posting you see only ' +
|
||||
'the plain facts stored about it: the job title, the employer, the locations and the ' +
|
||||
'countries, the employment labels such as intern or full time, the workplace kind such as ' +
|
||||
'remote or on site, the pay when any pay is stored, and the date it was posted. You are ' +
|
||||
'not given the job description, so the requirements written in it are not in front of you. ' +
|
||||
'Screen on the facts you have and do not guess at what a description you cannot read might ' +
|
||||
'say. Your ratings decide which of these postings get read properly later, description and ' +
|
||||
'all. So you are deciding what deserves a real look, not what deserves an application.\n' +
|
||||
'\n' +
|
||||
'After the postings comes the list of posting ids you must answer for, and then this ' +
|
||||
"person's profile: every document they keep, each labelled with the name it is stored " +
|
||||
'under. Four of those names mean something fixed:\n' +
|
||||
'\n' +
|
||||
'- constraints: rules this person cannot break, such as work authorization, where they can ' +
|
||||
'be, and when they are free. If a posting breaks a rule written there, its verdict is `no`, ' +
|
||||
'whatever else is true, and your reasoning must name the rule it breaks.\n' +
|
||||
'- background: what this person has done and can do.\n' +
|
||||
'- preferences: what this person wants and does not want.\n' +
|
||||
'- resume: attached as a PDF file with this message, when they have stored one. Read it as ' +
|
||||
"part of this person's profile.\n" +
|
||||
'\n' +
|
||||
'Any other document in the profile is part of this person too: read it and take it into ' +
|
||||
'account.\n' +
|
||||
'\n' +
|
||||
'Give one verdict for every posting id you were given, and for no other id. A verdict is ' +
|
||||
'exactly one of these four words:\n' +
|
||||
'\n' +
|
||||
'- `no`: a constraint is broken, or the posting has nothing to do with this person.\n' +
|
||||
'- `weak`: nothing rules it out, but the facts you can see point away from this person.\n' +
|
||||
'- `fair`: worth reading properly. The facts you can see line up, and the description would ' +
|
||||
'settle the rest.\n' +
|
||||
'- `strong`: the facts you can see line up well, and this looks like the kind of role this ' +
|
||||
'person is asking for.\n' +
|
||||
'\n' +
|
||||
'`fair` means "worth a real look", not "apply to this one". What settles that is the job ' +
|
||||
'description, and you have not read it.\n' +
|
||||
'\n' +
|
||||
'With each verdict give one or two sentences of reasoning, written for this person to read, ' +
|
||||
'saying what drove it. Grade honestly: a screen that calls everything strong has told this ' +
|
||||
'person nothing.\n' +
|
||||
'\n' +
|
||||
'Answer for every posting id you were given, including the ones whose facts are thin. A ' +
|
||||
'posting you say nothing about is reported to this person as one that could not be ' +
|
||||
'screened.\n';
|
||||
|
||||
/** The kind a reserved name must hold, or undefined for a name the user invented. */
|
||||
export function reservedKind(name: string): DocumentKind | undefined {
|
||||
return Object.prototype.hasOwnProperty.call(RESERVED_NAMES, name)
|
||||
? RESERVED_NAMES[name]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a legal document name looks like: lowercase letters, digits and dashes,
|
||||
* one to forty characters. The same rule is written as a CHECK constraint on
|
||||
* the table in supabase/migrations/20260813184000_profiles.sql, so a name that
|
||||
* somehow got past this one would still be refused by the database.
|
||||
*/
|
||||
export const NAME_RULE = /^[a-z0-9-]{1,40}$/;
|
||||
|
||||
/** The most bytes one text document may hold. */
|
||||
export const PER_DOCUMENT_CAP = 65_536;
|
||||
|
||||
/**
|
||||
* The most bytes all of one account's TEXT documents may hold together. An
|
||||
* uploaded file's bytes never live in the row, so they are not counted here;
|
||||
* a file is held to FILE_CAP on its own instead.
|
||||
*/
|
||||
export const WHOLE_PROFILE_CAP = 262_144;
|
||||
|
||||
/** The most bytes one uploaded file may hold. */
|
||||
export const FILE_CAP = 10 * 1024 * 1024;
|
||||
|
||||
/** The one content type an uploaded file may arrive as, today. */
|
||||
export const FILE_CONTENT_TYPE = 'application/pdf';
|
||||
|
||||
/** The filename a file gets when it was piped in and had no name of its own. */
|
||||
export const DEFAULT_FILE_NAME = 'resume.pdf';
|
||||
|
||||
/** The five characters every PDF begins with. */
|
||||
const PDF_HEADER = '%PDF-';
|
||||
|
||||
/**
|
||||
* True when these bytes start the way every PDF starts.
|
||||
*
|
||||
* Two places ask this and they ask it for different reasons. The server asks
|
||||
* before it stores anything, because a file that does not begin like a PDF is
|
||||
* refused. The command line asks so that a person who points a PDF at a document
|
||||
* name that holds text gets the server's refusal about that name, instead of
|
||||
* having their PDF quietly mangled into characters and stored as text.
|
||||
*/
|
||||
export function beginsLikeAPdf(bytes: Buffer): boolean {
|
||||
return bytes.subarray(0, PDF_HEADER.length).toString('latin1') === PDF_HEADER;
|
||||
}
|
||||
|
||||
/** The one document name that takes a file, named in the refusals below. */
|
||||
const THE_FILE_NAME = 'resume';
|
||||
|
||||
/** How a person is told to store the resume, repeated by several refusals. */
|
||||
const HOW_TO_STORE_A_FILE =
|
||||
`"pinloop profile put ${THE_FILE_NAME} --file <path to the PDF>"`;
|
||||
|
||||
/** What a person is told when the name they chose is not a legal name. */
|
||||
export function nameRuleRefusal(name: string): string {
|
||||
return (
|
||||
`${JSON.stringify(name)} is not a document name. A name is made of ` +
|
||||
`lowercase letters, digits, and dashes, up to 40 characters.`
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person is told when they hand plain text to a name that holds a file. */
|
||||
export function wrongKindRefusal(name: string): string {
|
||||
if (name === 'resume') {
|
||||
return (
|
||||
'the resume holds a PDF file, not text. Store it with ' +
|
||||
'"pinloop profile put resume --file <path to the PDF>".'
|
||||
);
|
||||
}
|
||||
return `the document named ${name} holds a file, not text.`;
|
||||
}
|
||||
|
||||
/** What a person is told when they upload a file to a name that holds text. */
|
||||
export function holdsTextRefusal(name: string): string {
|
||||
return (
|
||||
`the document named ${name} holds text, not a file. ` +
|
||||
`The ${THE_FILE_NAME} is the only document that takes a file: store it with ` +
|
||||
`${HOW_TO_STORE_A_FILE}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person is told when the file they uploaded is bigger than the cap. */
|
||||
export function overFileCapRefusal(size: number): string {
|
||||
return (
|
||||
`that file is ${size} bytes, which is over the 10 MB cap for a file ` +
|
||||
`(${FILE_CAP} bytes).`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a person is told when the file they uploaded does not start with the
|
||||
* five characters every PDF starts with. This is what somebody handing over a
|
||||
* text file, a Word document or a screenshot sees, so it also says in as many
|
||||
* words that the resume takes a PDF file rather than typed-out text.
|
||||
*/
|
||||
export function notAPdfRefusal(): string {
|
||||
return (
|
||||
'that file does not begin like a PDF: every PDF starts with the characters ' +
|
||||
`%PDF-, and this one does not. The ${THE_FILE_NAME} holds a PDF file, not text, ` +
|
||||
`so store the PDF itself with ${HOW_TO_STORE_A_FILE}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person is told when a PDF-looking file turns out not to open. */
|
||||
export function willNotOpenRefusal(said: string): string {
|
||||
return (
|
||||
`that file starts like a PDF but could not be opened as a PDF (${said}). ` +
|
||||
'Open it on your own machine, and if it opens there, save it again from the ' +
|
||||
'program that opened it and upload the saved copy.'
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person is told when one document on its own is too big. */
|
||||
export function perDocumentRefusal(size: number): string {
|
||||
return (
|
||||
`that document is ${size} bytes, and the cap per document is 64 KB ` +
|
||||
`(${PER_DOCUMENT_CAP} bytes).`
|
||||
);
|
||||
}
|
||||
|
||||
/** What a person is told when storing a document would take the profile over. */
|
||||
export function wholeProfileRefusal(wouldBe: number): string {
|
||||
return (
|
||||
`storing that document would put this profile at ${wouldBe} bytes, which is ` +
|
||||
`over the profile's 256 KB cap (${WHOLE_PROFILE_CAP} bytes).`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* STUB. Browser sign-in does not exist yet.
|
||||
*
|
||||
* Nothing here is implementation. Every name below is a number, an address, or
|
||||
* a printed sentence that both the tests of this feature and the build that
|
||||
* follows them read out of this one file, so that neither side can carry its
|
||||
* own copy and drift. It is the same arrangement src/cli/paths.ts has held
|
||||
* since slice 1, and the same one src/server/limits.ts holds for the ceilings.
|
||||
*
|
||||
* The feature is specs/feature-browser-sign-in.md, agreed 2026-08-19, with the
|
||||
* architecture agreed 2026-08-20. In one paragraph: `pinloop login` starts a
|
||||
* small web server inside its own process listening on 127.0.0.1, prints the
|
||||
* web address of the sign-in page with the port it is listening on and a
|
||||
* one-time secret in that address, and waits. A person opens that address in a
|
||||
* browser, signs in with Google, with GitHub, or with a 6-digit code Pinloop
|
||||
* emails them, and the script inside the page sends the pass to 127.0.0.1 on
|
||||
* the port the terminal named. When the browser cannot reach the terminal —
|
||||
* the terminal is on a rented machine reached over SSH and the browser is on a
|
||||
* laptop — the page shows a short code instead, and the person pastes that code
|
||||
* into the waiting terminal.
|
||||
*
|
||||
* This file is imported by the command line and by the server, so it lives in
|
||||
* src/shared: src/cli/package-boundary.test.ts allows the published command to
|
||||
* be built out of src/cli and src/shared and nothing else.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The release this feature ships as
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The version of the `pinloop` command this feature ships as, and the version
|
||||
* the server's MINIMUM_CLI_VERSION rises to on the day it ships.
|
||||
*
|
||||
* Why the minimum moves, which it almost never does. Every copy of `pinloop`
|
||||
* published before this one signs a person in by sending their password to two
|
||||
* server addresses that this feature deletes, so the sign-in in every one of
|
||||
* those copies is broken beyond repair from here. src/server/versions.ts says
|
||||
* the minimum moves only when one specific older copy is provably giving people
|
||||
* wrong answers or doing something unsafe, and a copy whose only way in no
|
||||
* longer exists meets that bar.
|
||||
*/
|
||||
export const BROWSER_SIGN_IN_RELEASE = '0.2.0';
|
||||
|
||||
/**
|
||||
* The release before this one: the last published copy that signs in with a
|
||||
* password. A request naming this version is the exact thing the server has to
|
||||
* refuse with the line naming `npm install -g pinloop`.
|
||||
*/
|
||||
export const RELEASE_BEFORE_BROWSER_SIGN_IN = '0.1.0';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Where the pages and the three new server addresses live
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Where the sign-in page is served from in production.
|
||||
*
|
||||
* It has to be this name rather than api.pinloop.ai, even though one Fly
|
||||
* machine answers both: the login service will only send a browser back to an
|
||||
* address on its allowed list, and that list names https://pinloop.ai/auth/callback
|
||||
* (specs/feature-browser-sign-in.md, the recorded settings of 2026-08-19).
|
||||
*/
|
||||
export const SIGN_IN_SITE_URL = 'https://pinloop.ai';
|
||||
|
||||
/**
|
||||
* Environment variable overriding where the sign-in page lives. Unset in
|
||||
* production; the tests point it at the server they started on this machine,
|
||||
* the same way PINLOOP_SERVER_URL in src/cli/paths.ts points the command at a
|
||||
* server.
|
||||
*/
|
||||
export const SIGN_IN_SITE_URL_ENV_VAR = 'PINLOOP_SIGN_IN_URL';
|
||||
|
||||
/** The page a person signs in on: Google, GitHub, or a box for an email address. */
|
||||
export const SIGN_IN_PAGE_PATH = '/login';
|
||||
|
||||
/**
|
||||
* The page the login service sends the browser back to once Google, GitHub or
|
||||
* the emailed code has proved who the person is. Its script is what delivers
|
||||
* the pass to the waiting terminal.
|
||||
*/
|
||||
export const CALLBACK_PAGE_PATH = '/auth/callback';
|
||||
|
||||
/** Asks Pinloop to email a 6-digit code to one address. Nobody is signed in yet. */
|
||||
export const SEND_CODE_PATH = '/auth/send-code';
|
||||
|
||||
/** Hands back a pass when the 6-digit code matches. Nobody is signed in yet. */
|
||||
export const VERIFY_CODE_PATH = '/auth/verify-code';
|
||||
|
||||
/**
|
||||
* Parks a pass in the server's memory under a short code, for the case where
|
||||
* the browser cannot reach the terminal. Nobody is signed in yet.
|
||||
*/
|
||||
export const HANDOFF_PATH = '/auth/handoff';
|
||||
|
||||
/** Trades that short code for the pass, once. Nobody is signed in yet. */
|
||||
export const HANDOFF_TRADE_PATH = '/auth/handoff/trade';
|
||||
|
||||
/**
|
||||
* The three addresses this feature adds that answer a caller who has not signed
|
||||
* in. Storing a held pass and trading it back are one address for the purpose
|
||||
* of counting requests, because they are two halves of the same fallback.
|
||||
*/
|
||||
export const SIGN_IN_ADDRESSES: readonly string[] = Object.freeze([
|
||||
SEND_CODE_PATH,
|
||||
VERIFY_CODE_PATH,
|
||||
HANDOFF_PATH,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The listener inside the terminal, and what the browser sends it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The one address the small web server inside `pinloop login` answers. */
|
||||
export const LOOPBACK_CALLBACK_PATH = '/callback';
|
||||
|
||||
/** The name in the printed web address that carries the port being listened on. */
|
||||
export const PORT_PARAMETER = 'port';
|
||||
|
||||
/**
|
||||
* The name in the printed web address that carries the one-time secret the
|
||||
* terminal invented when it started listening.
|
||||
*
|
||||
* The delivery has to carry the same secret back in its body. Without it, a
|
||||
* page still open from a different sign-in could hand a pass to the wrong
|
||||
* terminal, and anything else running on the machine could hand it one too.
|
||||
*/
|
||||
export const SECRET_PARAMETER = 'secret';
|
||||
|
||||
/**
|
||||
* What the page's script sends to the terminal, as JSON.
|
||||
*
|
||||
* The email address rides along rather than being read out of the pass. The
|
||||
* page already has it — the login service hands the account back with the
|
||||
* sign-in — and it is used for one thing only, the printed confirmation line,
|
||||
* never for deciding who anybody is. Who the caller is, is decided by the
|
||||
* server, from the pass, on every later request.
|
||||
*/
|
||||
export type DeliveredPass = {
|
||||
/** The one-time secret out of the printed address. */
|
||||
secret: string;
|
||||
/** The pass itself: what proves to the server who is running a command. */
|
||||
access_token: string;
|
||||
/** The renewal token, which buys the next pass when this one runs out. */
|
||||
refresh_token?: string;
|
||||
/** The address that signed in, for the confirmation line and nothing else. */
|
||||
email?: string;
|
||||
};
|
||||
|
||||
/** How long `pinloop login` waits for a browser before it gives up: 10 minutes. */
|
||||
export const WAIT_FOR_SIGN_IN_MS = 10 * 60 * 1000;
|
||||
|
||||
/** How long a short code shown by the page can be traded for the pass: 10 minutes. */
|
||||
export const HANDOFF_CODE_LIFETIME_MS = 10 * 60 * 1000;
|
||||
|
||||
/** How many digits the code Pinloop emails has. The live login service is set to this. */
|
||||
export const EMAILED_CODE_DIGITS = 6;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every sentence this feature prints, word for word
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The first line `pinloop login` prints, with the web address on its own
|
||||
* indented line under it.
|
||||
*
|
||||
* The address is always printed, even on a machine where the command also opens
|
||||
* the browser itself, because the person running the command is often a coding
|
||||
* agent and the human does the browser part on their own screen.
|
||||
*/
|
||||
export const OPEN_THIS_ADDRESS_LINE = 'open this address in your browser to sign in:';
|
||||
|
||||
/** How far the printed web address is indented under the line above it. */
|
||||
export const ADDRESS_INDENT = ' ';
|
||||
|
||||
/**
|
||||
* The line under the address, for the case where the browser is on a different
|
||||
* machine from the terminal and cannot reach it.
|
||||
*/
|
||||
export const PASTE_THE_CODE_LINE = 'if the page shows you a code, paste it here:';
|
||||
|
||||
/** The one line printed when nothing was delivered inside WAIT_FOR_SIGN_IN_MS. */
|
||||
export const GAVE_UP_LINE =
|
||||
'the sign-in did not finish within 10 minutes. Run "pinloop login" to try again.';
|
||||
|
||||
/**
|
||||
* The one line a finished sign-in prints. It is the line `pinloop login` has
|
||||
* printed since slice 1, unchanged: the browser is a new way of proving who
|
||||
* somebody is, and nothing about what the command does afterwards changes.
|
||||
*
|
||||
* Andrew's ruling (2026-08-22): no mention of a file, a path, or "this
|
||||
* machine" — that is implementation detail a consumer product does not
|
||||
* surface. The email address is kept because it confirms which account just
|
||||
* got signed in; everything about where the pass went is dropped.
|
||||
*/
|
||||
export function loggedInLine(email: string): string {
|
||||
return `logged in as ${email}`;
|
||||
}
|
||||
|
||||
/** The one line `pinloop logout` prints when it deleted a saved login. */
|
||||
export function signedOutLine(): string {
|
||||
return 'logout successful';
|
||||
}
|
||||
|
||||
/** The one line `pinloop logout` prints when there was nothing to delete. */
|
||||
export const NO_LOGIN_SAVED_LINE = 'not logged in';
|
||||
|
||||
/**
|
||||
* What somebody typing the old password sign-in is told.
|
||||
*
|
||||
* A copy of the command from before this release never reaches this sentence —
|
||||
* the server refuses it by version and tells it to install again. This sentence
|
||||
* is for a person who has the new copy and typed the flags from memory or from
|
||||
* a saved note.
|
||||
*/
|
||||
export const PASSWORD_FLAGS_REFUSAL =
|
||||
'pinloop login no longer takes --email or --password. ' +
|
||||
'Run "pinloop login" and finish signing in in the browser.';
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The short instruction file a coding agent saves on the person's own computer.
|
||||
*
|
||||
* The format is the one published at agentskills.io: a folder holding one file
|
||||
* called SKILL.md. Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, VS
|
||||
* Code and about forty other tools read it, and each of them looks in a
|
||||
* different folder for it. Pinloop cannot tell which tool is running, so
|
||||
* `pinloop skill` prints this text and the agent writes it wherever its own tool
|
||||
* looks. Pinloop never writes a file onto anybody's computer.
|
||||
*
|
||||
* Two rules hold this text down, and both are checked by
|
||||
* src/shared/skill-file.test.ts.
|
||||
*
|
||||
* It names no Pinloop command except `pinloop guide` and `pinloop message`. A
|
||||
* copy of this text saved in October is still sitting in somebody's skills
|
||||
* folder in March, and by then a command name or an option name written here may
|
||||
* describe something that has moved on. The full instructions ship inside the
|
||||
* installed program instead, so they always describe the copy of Pinloop the
|
||||
* person actually has.
|
||||
*
|
||||
* `pinloop message` was allowed in on 2026-08-20, on Andrew's call
|
||||
* (specs/feature-messages.md, AGREED that date), so that a person whose command
|
||||
* has just failed has a way to reach the person who can fix it without reading
|
||||
* the full instructions first. The consequence is written down in that spec:
|
||||
* from the day it ships, `pinloop message` can never be renamed, because copies
|
||||
* of this text naming it sit on other people's computers and nothing ever
|
||||
* updates them.
|
||||
*
|
||||
* It ends by telling the agent to run `pinloop guide --skill <this version>` and
|
||||
* read all of it. That is the one thing that makes the version comparison
|
||||
* happen every time this file is used, so an agent holding a copy from months
|
||||
* ago finds out that it is holding one.
|
||||
*
|
||||
* SKILL_VERSION is raised by hand, by one, whenever SKILL_TEXT changes. Nothing
|
||||
* raises it automatically, because a change of wording that does not matter
|
||||
* would then make every saved copy in the world announce itself as out of date.
|
||||
*
|
||||
* Every sentence below is Andrew's, approved 2026-08-19, and replaced the
|
||||
* placeholder draft shipped earlier the same day. The line breaks are hand
|
||||
* wrapping at about 80 columns and carry no meaning; the words themselves are
|
||||
* not to be edited without him.
|
||||
*/
|
||||
|
||||
/** The version of the text below. Raised by hand when the text changes. */
|
||||
export const SKILL_VERSION = 4;
|
||||
|
||||
/**
|
||||
* The text `pinloop skill` prints and an agent saves.
|
||||
*
|
||||
* The closing sentence is built from SKILL_VERSION rather than typed, so the
|
||||
* number in the text and the number in the code can never drift apart.
|
||||
*/
|
||||
export const SKILL_TEXT = `Pinloop searches job postings from a terminal.
|
||||
|
||||
Pinloop holds a large collection of real job postings which is updated ~hourly
|
||||
with fresh postings. It can also store plain text documents (.md, .txt) and the
|
||||
user's PDF resume. It can use these to make AI calls which judge job postings
|
||||
for the user, but this can also serve as an organized store of information to
|
||||
help you, the coding agent, assist the user with their job search.
|
||||
|
||||
The person you are working with might have a variety of goals, specific OR
|
||||
open-ended, for their job search, and your role is to help them achieve those
|
||||
goals, using the full extent of what Pinloop offers (as much as is helpful).
|
||||
You should act as an "interpreter" or "translator" of user requests/goals into
|
||||
Pinloop commands which fulfill what the user desires. This also means you
|
||||
should translate Pinloop outputs/prints such that the user understands what is
|
||||
going on. Every command prints lines a person can read, and the commands that
|
||||
hand back postings will print machine-readable rows instead when you ask for
|
||||
them, so the output of one command can be fed straight into the next one.
|
||||
|
||||
Assume the person does not know what Pinloop can and cannot do for them. They
|
||||
will not ask for its features by name, and they should not have to. When they
|
||||
tell you anything about their job search — the kind of work they want, a
|
||||
company they care about, that they keep missing new postings, that they want a
|
||||
second opinion on which postings are worth their time — recognize when
|
||||
something Pinloop offers serves that, and offer it at that moment in plain
|
||||
words: what you would do, and what they would get back. When a request is
|
||||
open-ended, make a call on how to serve it and say what you chose, rather than
|
||||
handing back a list of options. When they expect something Pinloop does not do,
|
||||
such as filling in or submitting an application, say so plainly rather than
|
||||
letting them find out later.
|
||||
|
||||
Treat the person as a capable adult running their own job search. The decisions
|
||||
are theirs — where to apply, what to spend their time on — and your job is to
|
||||
put accurate information in front of them, not to decide for them or to inflate
|
||||
their prospects. A job search is stressful, so be straight without being harsh:
|
||||
report what Pinloop actually returned, including when a search comes back thin
|
||||
or a judgment on a posting is negative, because an honest reading of a weak
|
||||
match is worth more to them than encouragement. Never tell the person something
|
||||
happened without having read the output that shows it happened, and when you
|
||||
are not sure what happened, say so and check rather than guessing.
|
||||
|
||||
This file is short on purpose, and it names one command. The set of commands
|
||||
changes, and a copy of this file saved months ago would be describing a Pinloop
|
||||
that has moved on. The full instructions ship inside the program on this
|
||||
machine, so they always describe the commands this machine actually has.
|
||||
|
||||
One command is named here, and only one, because a person whose Pinloop command
|
||||
just failed should not have to read the full instructions first to find out how
|
||||
to tell anybody. If something goes wrong — a command fails, an answer looks
|
||||
wrong, something the person expected is missing — run pinloop message followed
|
||||
by what you want to say, and Andrew, who builds Pinloop, will read it and answer
|
||||
you in the same place. Write the draft yourself, show the person the exact words
|
||||
you are about to send, and send only after they say yes. When a line appears
|
||||
saying an answer from Andrew is waiting, run pinloop message and give the person
|
||||
his answer word for word.
|
||||
|
||||
Before you run anything else for this person, run pinloop guide --skill ${SKILL_VERSION} and
|
||||
read all of it, so you know what you are able to offer them.
|
||||
`;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The four words a verdict may be, and the two small things every side of the
|
||||
* product does with them.
|
||||
*
|
||||
* A verdict is the model's own word about one job posting and one person: `no`,
|
||||
* `weak`, `fair` or `strong`, worst to best. The order matters, because a run
|
||||
* can be told to keep only postings at or above one of the four words, so the
|
||||
* list below is a scale rather than a set.
|
||||
*
|
||||
* These four words are written down once, here, and everything else reads them
|
||||
* from here. Three different places need them and they must never disagree.
|
||||
* The file that builds the model request puts the four words in the request as
|
||||
* the only answers the model may give, and refuses an answer outside them. The
|
||||
* judge run and the judgment listing check a typed verdict against them. And
|
||||
* the command line prints a line for every posting a keep word dropped, which
|
||||
* means the command line needs the wording of that line too — `dropReason`
|
||||
* below — without needing anything else the judge run does.
|
||||
*
|
||||
* That last point is why this file sits under src/shared/ rather than inside
|
||||
* the judge run. `pinloop judge --keep fair` prints "dropped <id>: judged weak,
|
||||
* below fair" on the caller's own machine, from a one-line function. Before
|
||||
* this file existed that one line lived in src/core/judge.ts, so the command
|
||||
* line imported the whole judge run to get it, and through the judge run it
|
||||
* imported the model provider, the search, the fetch, the profile reader and
|
||||
* the embedder — about a dozen files of server code that the published
|
||||
* `pinloop` package has no use for and must not carry.
|
||||
*
|
||||
* Nothing in this file opens a connection, reads a credential, calls a model or
|
||||
* touches the filesystem. It is four words and two functions over them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The four words a verdict may be, worst to best.
|
||||
*
|
||||
* Growing or reordering this list changes what the model is allowed to answer,
|
||||
* what `--keep` compares against, and what `pinloop judgment put` will store,
|
||||
* all at once. It is a spec change (specs/feature-judge.md), never a tidy-up.
|
||||
*/
|
||||
export const VERDICTS = ['no', 'weak', 'fair', 'strong'] as const;
|
||||
|
||||
/** One of the four words, as a type. */
|
||||
export type Verdict = (typeof VERDICTS)[number];
|
||||
|
||||
/**
|
||||
* Why a judge step in a routine, or a judge run in a terminal, left a posting
|
||||
* out: the verdict it was given and the word the run was keeping at, so a person
|
||||
* reading the report sees both the judgement and the rule that acted on it.
|
||||
*/
|
||||
export function dropReason(verdict: string, keep: string): string {
|
||||
return `judged ${verdict}, below ${keep}`;
|
||||
}
|
||||
|
||||
/** Where a verdict sits on the four-word scale; higher is better. */
|
||||
export function rankOf(verdict: string): number {
|
||||
return VERDICTS.indexOf(verdict as Verdict);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Reading and comparing the version numbers the installed `pinloop` command and
|
||||
* the server tell each other about.
|
||||
*
|
||||
* Why this file exists, and why it sits in src/shared rather than on one side
|
||||
* or the other. The installed command sends its own version number to the
|
||||
* server on every request. The server sends two numbers back on every answer:
|
||||
* the newest version of the command that has been published, and the oldest
|
||||
* version the server will still answer at all. Both sides then have to decide
|
||||
* whether one version number is below another. The server decides it to work
|
||||
* out whether to refuse the request outright. The command decides it to work
|
||||
* out whether to print the line saying a newer copy exists. If the two pieces
|
||||
* of code did that differently, a copy could be refused by the server while
|
||||
* being told nothing at all by itself, so the comparison is written once here
|
||||
* and imported by both.
|
||||
*
|
||||
* What breaks silently if this file is wrong. Version numbers look like text
|
||||
* and are not text. Compared as text, "0.9.0" sorts after "0.10.0", because the
|
||||
* character "9" comes after the character "1". A comparison that made that
|
||||
* mistake would tell a person running 0.10.0 to install 0.9.0, and the day the
|
||||
* oldest answered version passed 0.9.0 it would cut that person off while they
|
||||
* were running the newest copy in existence. Nothing would fail anywhere; the
|
||||
* server would simply stop answering. The other silent failure is answering
|
||||
* "these are the same version" for text that is not a version number at all,
|
||||
* because "the same version" is exactly what an up-to-date copy looks like, so
|
||||
* an unreadable number would be read as up to date forever. That is why
|
||||
* compareVersions throws rather than guessing, and why isVersion exists to give
|
||||
* a plain yes or no about a piece of text before anybody compares it.
|
||||
*
|
||||
* This file touches nothing outside itself: no network, no database, no money.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when `text` is three whole numbers separated by dots, and nothing else.
|
||||
*
|
||||
* Anything else is false: an empty string, "1.2", "1.2.3.4", "v1.2.3",
|
||||
* "1.2.3-beta", a number with a space in front of it. The server needs this
|
||||
* answer because a version header holding text of any other shape has to be
|
||||
* treated exactly like a request that carried no version header at all.
|
||||
*/
|
||||
export function isVersion(text: string): boolean {
|
||||
return /^\d+\.\d+\.\d+$/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two version numbers and returns a negative number when `a` is older
|
||||
* than `b`, zero when they are the same version, and a positive number when `a`
|
||||
* is newer than `b`.
|
||||
*
|
||||
* Only the sign of the answer means anything, which is what lets this be handed
|
||||
* straight to a sort. Each of the three parts is read as a number rather than
|
||||
* as text, and the first part decides before the second is looked at, so 1.0.0
|
||||
* is newer than 0.99.99 and 0.1.20 is newer than 0.1.3.
|
||||
*
|
||||
* Text that is not a version number throws instead of being given an answer.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const left = partsOf(a);
|
||||
const right = partsOf(b);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const difference = left[index]! - right[index]!;
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The three whole numbers inside a version number, or a refusal to read it. */
|
||||
function partsOf(text: string): number[] {
|
||||
if (!isVersion(text)) {
|
||||
throw new Error(
|
||||
`${JSON.stringify(text)} is not a version number of the form 1.2.3. This is a ` +
|
||||
'Pinloop problem, not something you did. Run `pinloop message` and describe ' +
|
||||
'what you were doing when it happened.',
|
||||
);
|
||||
}
|
||||
return text.split('.').map((part) => Number(part));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* What `pinloop welcome` prints.
|
||||
*
|
||||
* `pinloop welcome` is the first thing a coding agent runs. It prints the same
|
||||
* text whether or not anybody is signed in, and it contacts no server at all:
|
||||
* every word of it ships inside the installed program. It is written for the
|
||||
* agent, not for the person.
|
||||
*
|
||||
* It prints five parts, always in this order:
|
||||
*
|
||||
* 1. WHAT_PINLOOP_IS one sentence saying what Pinloop is and who it is
|
||||
* for, so the agent can explain it in its own words.
|
||||
* 2. SKILL_PART the short instruction file's text, with a line
|
||||
* telling the agent to save it into its own skills
|
||||
* folder now.
|
||||
* 3. HOSTING_PARAGRAPHS what a good host does with the person in front of
|
||||
* it.
|
||||
* 4. FIRST_STEPS_OPENING the paragraph that introduces the ordered steps.
|
||||
* 5. FIRST_STEPS the ordered first steps themselves.
|
||||
*
|
||||
* The steps are records rather than lines of prose. Each one carries a `kind`
|
||||
* saying what the step is for, an `explanation` holding the step's words, and an
|
||||
* optional `ask` for a step whose request to the person is a separate closing
|
||||
* sentence. The order of the kinds is fixed and is checked by
|
||||
* src/shared/welcome.test.ts: the full instructions are read first, then the
|
||||
* person is signed in, then given the peek, then asked for their resume, then
|
||||
* shown Pinloop judging the peek's postings against that resume, and then told
|
||||
* what they might do next. None of the six steps shipping today carries a
|
||||
* separate `ask`, because in Andrew's approved text every request to the person
|
||||
* sits inside the step's own paragraph. The field and printStep's ask-last
|
||||
* behaviour stay because later wording may use them again.
|
||||
*
|
||||
* `pinloop login` used to end by printing a second block of onboarding text: a
|
||||
* conditional opening sentence, the email address, and the steps that remained.
|
||||
* Andrew removed it on 2026-08-19. Somebody who opens a second terminal and
|
||||
* signs in again got a paragraph of instructions printed at them, which reads
|
||||
* badly, and an agent that ran the login command itself already knows the person
|
||||
* just signed in, so the paragraph told it nothing it did not have. `pinloop
|
||||
* login` now prints only the plain confirmation of the sign-in. Nothing in this
|
||||
* file is printed on sign-in any more, and src/shared/welcome.test.ts fails if
|
||||
* that block comes back.
|
||||
*
|
||||
* Every printed sentence below is Andrew's, approved 2026-08-19, and replaced
|
||||
* the placeholder draft shipped earlier the same day. The line breaks are hand
|
||||
* wrapping at about 80 columns and carry no meaning; the words themselves are
|
||||
* not to be edited without him.
|
||||
*
|
||||
* Amended 2026-08-22 (specs/feature-welcome-first-steps.md), every word of the
|
||||
* amendment approved by Andrew that day: the four ordered steps became six.
|
||||
* The 'profile' step, which gets the person's resume stored, and the 'judge'
|
||||
* step, which shows Pinloop judging the peek's postings against that resume,
|
||||
* are new. The 'closing' step was replaced. The paragraph introducing the steps
|
||||
* gained the sentence telling the agent that the person may leave the steps at
|
||||
* any point, and the 'guide' step gained the announcement the agent makes to
|
||||
* the person before anything else happens.
|
||||
*/
|
||||
import { SKILL_TEXT } from './skill-file.ts';
|
||||
|
||||
/** One step of the ordered first steps. */
|
||||
export type WelcomeStep = {
|
||||
/**
|
||||
* What the step is for: 'guide', 'login', 'peek', 'profile', 'judge',
|
||||
* 'closing', and more later.
|
||||
*/
|
||||
kind: string;
|
||||
/** Why the agent is doing this step, and what doing it looks like. */
|
||||
explanation: string;
|
||||
/** The one sentence that asks the person for something, when there is one. */
|
||||
ask?: string;
|
||||
};
|
||||
|
||||
/** Part one: what Pinloop is and who it is for. */
|
||||
export const WHAT_PINLOOP_IS = `Pinloop is a job search tool driven from a terminal, and it is built for people
|
||||
who already work with a coding agent like you.`;
|
||||
|
||||
/** Part two: the instruction file's text, and the line telling the agent to save it. */
|
||||
export const SKILL_PART = `Save the text between the lines below into your own skills folder now, as a file
|
||||
called SKILL.md inside a folder of its own. Save it before you do anything else,
|
||||
because it is what tells you to come back and read the full instructions later.
|
||||
|
||||
----------------------------------------------------------------------
|
||||
${SKILL_TEXT.trim()}
|
||||
----------------------------------------------------------------------`;
|
||||
|
||||
/** Part three: how to look after the person in front of you. */
|
||||
export const HOSTING_PARAGRAPHS = `Explain what you are about to do before you do it, in one or two plain
|
||||
sentences, and say what it will cost the person in time or in effort. When a
|
||||
request is open-ended, make a call on how to serve it with Pinloop and say what
|
||||
you chose, rather than handing back a list of options.`;
|
||||
|
||||
/**
|
||||
* Part four: the paragraph that introduces the ordered steps.
|
||||
*
|
||||
* It holds the rule about never ending a turn without giving the person
|
||||
* something to say next, which is why it is a paragraph rather than the one
|
||||
* short line it used to be.
|
||||
*/
|
||||
export const FIRST_STEPS_OPENING = `For the following first steps, don't end a turn without giving the person
|
||||
something they can say next: if you have just shown them something, say what
|
||||
you can do with it, and if you are waiting on them, say exactly what you need.
|
||||
The person can leave these steps at any point: if they say they want to skip
|
||||
the onboarding, stop the steps right there and go where they point. The first
|
||||
steps, in order:`;
|
||||
|
||||
/** Part five: the ordered first steps, printed once by `pinloop welcome`. */
|
||||
export const FIRST_STEPS: readonly WelcomeStep[] = Object.freeze([
|
||||
{
|
||||
kind: 'guide',
|
||||
explanation: `Before anything else, tell the person this, in these words or close to them:
|
||||
"We'll start the Pinloop onboarding now (~10 minutes) to get you set up. It's
|
||||
just a back and forth conversation, and nothing gets run without you knowing.
|
||||
If at any point you want to skip it, just let me know."
|
||||
|
||||
Then read Pinloop's full instructions before you run any other command: run
|
||||
pinloop guide and read all of it. It works signed out. It covers every command,
|
||||
every option, the shape of the machine-readable output, the limits and worked
|
||||
examples, and it is generated from the copy of Pinloop installed on this
|
||||
machine, so it matches the commands this machine actually has. This costs the
|
||||
person nothing — read it yourself, without making them wait on a summary of it,
|
||||
so that everything you run from here on is a command you actually understand.`,
|
||||
},
|
||||
{
|
||||
kind: 'login',
|
||||
explanation: `Nothing else works until the person has a Pinloop account. Direct the user to
|
||||
run the login command(s) the guide names. Tell them what will happen when they
|
||||
run it, and ask them to sign in. Be very concise here.`,
|
||||
},
|
||||
{
|
||||
kind: 'peek',
|
||||
explanation: `Once the person has signed in, before you ask the person for anything else,
|
||||
show them that Pinloop holds real jobs for them. This is just a throwaway peek,
|
||||
not the description Pinloop will work from: nothing from their answer is stored
|
||||
anywhere or used by Pinloop long-term. Say that it is a quick look, so nobody
|
||||
thinks that this sentence has to stand in for their whole job search. Be
|
||||
concise here too. Ask them for a job title, or a few words describing the work
|
||||
they do. Decide whether a semantic search or a normal keyword search would be
|
||||
best based on what the user says. Then search them up and show them eight or
|
||||
ten *recent* postings, each with the company, the title, the location and how
|
||||
long ago it was posted.`,
|
||||
},
|
||||
{
|
||||
kind: 'profile',
|
||||
explanation: `After the peek, get the person's resume into Pinloop. This is the one quick
|
||||
step that lets Pinloop judge postings against them properly, so frame it that
|
||||
way: a resume is all it needs to get started, and there's more they can add
|
||||
later, which you'll come back to at the end. Don't ask them for anything else
|
||||
here, and don't interview them about what they want. Offer the three ways this
|
||||
can happen in one sentence: they can drag the file into the terminal, or tell
|
||||
you the file path, or you can find it on their computer if they want you to.
|
||||
Never go looking on their computer unless they say yes. Once you have the file,
|
||||
store it with pinloop profile put and confirm it's in. Be concise here too.`,
|
||||
},
|
||||
{
|
||||
kind: 'judge',
|
||||
explanation: `Once the resume is stored, show the person what Pinloop actually does: take the
|
||||
postings from the peek and judge them against the resume they just gave you.
|
||||
Tell them what's about to happen in a sentence first, and say roughly how long
|
||||
each part will take before you start it, so they're not sitting there wondering
|
||||
if anything is happening. Run pinloop judge --quick over all of the peek's
|
||||
postings, which screens each one on its plain facts in a single pass and
|
||||
usually comes back within a minute, and show them the verdicts. Then take the
|
||||
one that screened strongest and judge it fully with pinloop judge, which reads
|
||||
the whole posting against their resume and explains itself, taking about a
|
||||
minute or so, and show them that verdict with its reasoning. This is the first
|
||||
real judgment they've seen, so don't bury it: put the verdict and the reasoning
|
||||
in front of them plainly and let them read it before you say anything else.
|
||||
Don't tell them how much of their usage this spent or what they have left;
|
||||
that comes at the close, read from their account.`,
|
||||
},
|
||||
{
|
||||
kind: 'closing',
|
||||
explanation: `To close the first steps, after the person has read the verdict, give them the
|
||||
lay of the land in plain words so they know what is in the room, then let them
|
||||
pick. Say exactly this:
|
||||
|
||||
"That verdict was made against your resume. Anything else you tell me about
|
||||
what you want (e.g., a written description of the job you're after, or the
|
||||
things that would rule a posting out for you) goes into your profile and makes
|
||||
every future verdict sharper. Tell me whenever.
|
||||
|
||||
Finding jobs from here is open ended. Describe what you want in plain English,
|
||||
a job title, a paragraph about the work you're looking for, a company, a list
|
||||
of conditions, and I'll work out how to get it out of Pinloop.
|
||||
|
||||
Judging comes in two sizes. A quick screen takes a big batch of postings at
|
||||
once and judges each one on its plain facts, the title, the company, the
|
||||
location and so on, without reading the full job description, so it covers a
|
||||
lot of ground quickly. A full judgment reads the whole posting against your
|
||||
profile and gives you the reasoning, like the one you just saw.
|
||||
|
||||
Anything Pinloop finds can go into a tab, which is just a named list you keep
|
||||
postings in, so the good ones don't get lost.
|
||||
|
||||
Once we've worked out a way of finding and judging jobs that you like, I can
|
||||
save it as a routine and run it again with one command. A schedule runs that
|
||||
routine every few hours on its own. A watch runs it only over the postings that
|
||||
have arrived since the last time it looked. All of that lives on Pinloop's
|
||||
servers, so it keeps going while you're away and the results are waiting when
|
||||
you come back.
|
||||
|
||||
If something is wrong or you want to ask for anything, I can run pinloop
|
||||
message to tell Andrew about it (he builds Pinloop). He'd love to get your
|
||||
feedback, and you'll get his response straight through this CLI.
|
||||
|
||||
I've read the full instructions for Pinloop, so if you want to explore anything
|
||||
further, just say so and we'll go deeper."
|
||||
|
||||
Then tell the person where they stand: read how much of this month's usage
|
||||
they have left back from their account rather than working it out, and say it
|
||||
plainly. Then say: "If you upgrade, you get over 5,000 quickly judged postings
|
||||
a month, or 400 full judgments, plus unlimited plain-English searches (if
|
||||
you're not spamming hundreds per minute). I can run pinloop upgrade if you want
|
||||
to learn more."
|
||||
|
||||
Don't name a price yourself. Leave all of this as things they can pick from,
|
||||
not a list to follow, and go where they point.`,
|
||||
},
|
||||
]);
|
||||
|
||||
/** One step as it appears on the terminal: the explanation, then the ask last. */
|
||||
export function printStep(step: WelcomeStep): string {
|
||||
const explanation = step.explanation.trim();
|
||||
const ask = step.ask?.trim() ?? '';
|
||||
return ask === '' ? explanation : `${explanation}\n\n${ask}`;
|
||||
}
|
||||
|
||||
/** The parts of the welcome, each one replaceable so a test can find the slot. */
|
||||
export type WelcomeParts = {
|
||||
whatPinloopIs?: string;
|
||||
skillPart?: string;
|
||||
hostingParagraphs?: string;
|
||||
firstStepsOpening?: string;
|
||||
firstSteps?: readonly WelcomeStep[];
|
||||
};
|
||||
|
||||
/** The whole of what `pinloop welcome` prints: five parts, in order. */
|
||||
export function printWelcome(parts: WelcomeParts = {}): string {
|
||||
const whatPinloopIs = parts.whatPinloopIs ?? WHAT_PINLOOP_IS;
|
||||
const skillPart = parts.skillPart ?? SKILL_PART;
|
||||
const hostingParagraphs = parts.hostingParagraphs ?? HOSTING_PARAGRAPHS;
|
||||
const firstStepsOpening = parts.firstStepsOpening ?? FIRST_STEPS_OPENING;
|
||||
const firstSteps = parts.firstSteps ?? FIRST_STEPS;
|
||||
|
||||
const blocks: string[] = [whatPinloopIs.trim(), skillPart.trim()];
|
||||
if (hostingParagraphs.trim() !== '') blocks.push(hostingParagraphs.trim());
|
||||
blocks.push(firstStepsOpening.trim());
|
||||
for (const step of firstSteps) blocks.push(printStep(step));
|
||||
|
||||
return `${blocks.filter((block) => block !== '').join('\n\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": false,
|
||||
"declaration": false
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user