@hypercarrier/rarebit 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +14 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LICENSE +21 -0
  4. package/README.md +142 -0
  5. package/RUNBOOK.md +36 -0
  6. package/SECURITY.md +11 -0
  7. package/VISUAL-LANGUAGE.md +89 -0
  8. package/bin/rarebit.mjs +20 -0
  9. package/package.json +96 -0
  10. package/schemas/rarebit.schema.json +618 -0
  11. package/scripts/e2e-recall-pi083.mjs +199 -0
  12. package/scripts/e2e-summary-cache.mjs +257 -0
  13. package/scripts/verify-package.mjs +81 -0
  14. package/src/automatic-summary-policy.mjs +210 -0
  15. package/src/extension.mjs +516 -0
  16. package/src/herdr-activity.mjs +111 -0
  17. package/src/index.mjs +13 -0
  18. package/src/lifecycle.mjs +322 -0
  19. package/src/rarebit-activity.mjs +48 -0
  20. package/src/rarebit-artifact-state.mjs +839 -0
  21. package/src/rarebit-cli.mjs +305 -0
  22. package/src/rarebit-command.mjs +281 -0
  23. package/src/rarebit-core.mjs +525 -0
  24. package/src/rarebit-model.mjs +226 -0
  25. package/src/rarebit-recall.mjs +182 -0
  26. package/src/rarebit-service.mjs +579 -0
  27. package/src/rarebit-session.mjs +227 -0
  28. package/src/rarebit-settings.mjs +79 -0
  29. package/src/rarebit-store.mjs +771 -0
  30. package/src/rarebit-visual-language.mjs +110 -0
  31. package/src/types.d.ts +601 -0
  32. package/test/automatic-summary-policy.test.mjs +476 -0
  33. package/test/extension-activity.test.mjs +73 -0
  34. package/test/fixtures/branch-mixed.json +117 -0
  35. package/test/fixtures/conformance-corpus.v1.json +306 -0
  36. package/test/fixtures/truncated-mixed.jsonl +2 -0
  37. package/test/herdr-activity.test.mjs +93 -0
  38. package/test/lifecycle-contract-independent.test.mjs +627 -0
  39. package/test/rarebit-activity.test.mjs +82 -0
  40. package/test/rarebit-artifact-state.test.mjs +440 -0
  41. package/test/rarebit-cli.test.mjs +222 -0
  42. package/test/rarebit-command.test.mjs +127 -0
  43. package/test/rarebit-core.test.mjs +461 -0
  44. package/test/rarebit-cross-consumer-conformance.test.mjs +150 -0
  45. package/test/rarebit-independent-contract.test.mjs +317 -0
  46. package/test/rarebit-recall.test.mjs +434 -0
  47. package/test/rarebit-sidecar-protocol.test.mjs +538 -0
  48. package/test/rarebit-visual-language.test.mjs +75 -0
  49. package/test/summary-attention-independent.test.mjs +172 -0
  50. package/test/types-history.ts +13 -0
@@ -0,0 +1,305 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveRarebitSettings } from "./rarebit-settings.mjs";
6
+ import {
7
+ processRarebitSummary,
8
+ processRarebitTitle,
9
+ } from "./rarebit-service.mjs";
10
+ import {
11
+ extractRarebits,
12
+ queryRarebits,
13
+ readRarebitSession,
14
+ } from "./rarebit-session.mjs";
15
+
16
+ export const RAREBIT_CLI_USAGE = `Usage:
17
+ rarebit query --session <exact-path-or-id> --json
18
+ rarebit extract --session <exact-path-or-id> --json
19
+ rarebit summarize --session <exact-path-or-id> [--force] [--model-command <executable> [--model-arg <arg>]] --json
20
+ rarebit title --session <exact-path-or-id> [--date YYYY-MM-DD] [--model-command <executable> [--model-arg <arg>]] --json
21
+
22
+ Normal summarize/title resolve rarebit.model from Pi settings. The optional model-command
23
+ adapter receives one prompt on stdin and must write only model text to stdout.
24
+ query is metadata-only; extract exposes raw selected Rarebit prose on demand.`;
25
+
26
+ function requiredValue(argv, index, flag) {
27
+ const value = argv[index + 1];
28
+ if (!value || value.startsWith("--"))
29
+ throw new Error(`${flag} requires a value`);
30
+ return value;
31
+ }
32
+
33
+ export function parseRarebitCliArgs(argv) {
34
+ if (argv.includes("--help") || argv.includes("-h")) return { help: true };
35
+ const [command, ...rest] = argv;
36
+ if (!new Set(["query", "extract", "summarize", "title"]).has(command))
37
+ throw new Error(
38
+ "First argument must be query, extract, summarize, or title",
39
+ );
40
+ const options = { command, modelArgs: [], json: false, force: false };
41
+ for (let index = 0; index < rest.length; index += 1) {
42
+ const argument = rest[index];
43
+ switch (argument) {
44
+ case "--session":
45
+ options.session = requiredValue(rest, index, argument);
46
+ index += 1;
47
+ break;
48
+ case "--model-command":
49
+ options.modelCommand = requiredValue(rest, index, argument);
50
+ index += 1;
51
+ break;
52
+ case "--model-arg":
53
+ options.modelArgs.push(requiredValue(rest, index, argument));
54
+ index += 1;
55
+ break;
56
+ case "--date":
57
+ options.date = requiredValue(rest, index, argument);
58
+ index += 1;
59
+ break;
60
+ case "--json":
61
+ options.json = true;
62
+ break;
63
+ case "--force":
64
+ options.force = true;
65
+ break;
66
+ default:
67
+ throw new Error(`Unknown argument: ${argument}`);
68
+ }
69
+ }
70
+ if (!options.session) throw new Error("--session is required");
71
+ if (!options.json) throw new Error("--json is required");
72
+ if (options.force && command !== "summarize")
73
+ throw new Error("--force only applies to summarize");
74
+ return options;
75
+ }
76
+
77
+ export function runModelCommand(
78
+ command,
79
+ args,
80
+ prompt,
81
+ { spawnProcess = spawn, env } = {},
82
+ ) {
83
+ return new Promise((resolve, reject) => {
84
+ const child = spawnProcess(command, args, {
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ shell: false,
87
+ env,
88
+ });
89
+ let stdout = "";
90
+ let stderr = "";
91
+ child.stdout.setEncoding("utf8");
92
+ child.stderr.setEncoding("utf8");
93
+ child.stdout.on("data", (chunk) => {
94
+ stdout += chunk;
95
+ });
96
+ child.stderr.on("data", (chunk) => {
97
+ stderr += chunk;
98
+ });
99
+ child.on("error", (error) =>
100
+ reject(
101
+ new Error(`Cannot start model command ${command}: ${error.message}`),
102
+ ),
103
+ );
104
+ child.on("close", (code, signal) => {
105
+ if (code === 0) return resolve(stdout);
106
+ const detail =
107
+ stderr.trim() ||
108
+ `exit ${code ?? "null"}${signal ? ` (${signal})` : ""}`;
109
+ reject(new Error(`Model command failed: ${detail}`));
110
+ });
111
+ child.stdin.end(prompt);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * The normal CLI adapter deliberately invokes Pi in a stripped, ephemeral
117
+ * print mode. Pi—not this package—continues to own credentials, provider
118
+ * configuration, proxy behavior, and model transport.
119
+ */
120
+ export function runPiRarebitModel({
121
+ model,
122
+ prompt,
123
+ agentDir,
124
+ piCommand = "pi",
125
+ spawnProcess = spawn,
126
+ }) {
127
+ const args = [
128
+ "--print",
129
+ "--no-session",
130
+ "--no-tools",
131
+ "--no-extensions",
132
+ "--no-skills",
133
+ "--no-context-files",
134
+ "--no-prompt-templates",
135
+ "--model",
136
+ `${model.provider}/${model.id}`,
137
+ "--system-prompt",
138
+ "Return only the requested result. Do not use tools.",
139
+ ];
140
+ return runModelCommand(piCommand, args, prompt, {
141
+ spawnProcess,
142
+ env: {
143
+ ...process.env,
144
+ ...(agentDir ? { PI_CODING_AGENT_DIR: agentDir } : {}),
145
+ },
146
+ });
147
+ }
148
+
149
+ function configuredAgentDir(env = process.env) {
150
+ return env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
151
+ }
152
+
153
+ async function jsonFile(path) {
154
+ try {
155
+ return JSON.parse(await readFile(path, "utf8"));
156
+ } catch (error) {
157
+ if (error?.code === "ENOENT") return {};
158
+ throw new Error(`Cannot read Pi settings ${path}: ${error.message}`);
159
+ }
160
+ }
161
+
162
+ /** Read the narrow, explicit model/policy contract shared with the Pi shell. */
163
+ export async function readRarebitCliSettings({
164
+ agentDir = configuredAgentDir(),
165
+ cwd = process.cwd(),
166
+ } = {}) {
167
+ const globalPath = join(agentDir, "settings.json");
168
+ const projectPath = join(cwd, ".pi", "settings.json");
169
+ const resolved = resolveRarebitSettings(
170
+ await jsonFile(globalPath),
171
+ await jsonFile(projectPath),
172
+ );
173
+ if (!resolved.model)
174
+ throw new Error(
175
+ resolved.modelConfigurationError ??
176
+ "Rarebit model is not configured; set rarebit.model to provider/model in Pi settings",
177
+ );
178
+ return {
179
+ agentDir,
180
+ model: resolved.model,
181
+ modelProvenance: {
182
+ source: "pi_settings_files",
183
+ settingsKey: "rarebit.model",
184
+ status: "resolved",
185
+ rawRefs: [globalPath, projectPath],
186
+ },
187
+ summaryPolicy: resolved.summaryPolicy,
188
+ autoTitle: resolved.autoTitle,
189
+ };
190
+ }
191
+
192
+ function contextForLoadedSession(loaded) {
193
+ return {
194
+ sessionManager: {
195
+ getHeader: () => ({ id: loaded.session.id }),
196
+ getSessionFile: () => loaded.sessionFile,
197
+ getBranch: () => loaded.branch,
198
+ },
199
+ };
200
+ }
201
+
202
+ function sessionDate(session, explicitDate) {
203
+ if (explicitDate) return explicitDate;
204
+ const timestamp = session?.startedAt;
205
+ if (typeof timestamp === "string" && /^\d{4}-\d{2}-\d{2}/.test(timestamp))
206
+ return timestamp.slice(0, 10);
207
+ throw new Error(
208
+ "--date YYYY-MM-DD is required because the persisted Session has no ISO start date",
209
+ );
210
+ }
211
+
212
+ async function modelRuntime(options, dependencies) {
213
+ if (options.modelCommand) {
214
+ return {
215
+ model: { provider: "external-command", id: options.modelCommand },
216
+ modelProvenance: { source: "explicit_model_command", status: "resolved" },
217
+ summaryPolicy: {},
218
+ complete: (prompt) =>
219
+ dependencies.runModelCommand(
220
+ options.modelCommand,
221
+ options.modelArgs,
222
+ prompt,
223
+ ),
224
+ };
225
+ }
226
+ const settings = await dependencies.readSettings();
227
+ return {
228
+ ...settings,
229
+ complete: (prompt) =>
230
+ dependencies.runPiModel({
231
+ model: settings.model,
232
+ prompt,
233
+ agentDir: settings.agentDir,
234
+ }),
235
+ };
236
+ }
237
+
238
+ export async function runRarebitCli(options, dependencies = {}) {
239
+ const runtimeDependencies = {
240
+ readSettings: dependencies.readSettings ?? readRarebitCliSettings,
241
+ runPiModel: dependencies.runPiModel ?? runPiRarebitModel,
242
+ runModelCommand: dependencies.runModelCommand ?? runModelCommand,
243
+ processSummary: dependencies.processSummary ?? processRarebitSummary,
244
+ processTitle: dependencies.processTitle ?? processRarebitTitle,
245
+ };
246
+ if (options.command === "query") return queryRarebits(options.session);
247
+ if (options.command === "extract") return extractRarebits(options.session);
248
+
249
+ const loaded = await readRarebitSession(options.session);
250
+ const runtime = await modelRuntime(options, runtimeDependencies);
251
+ if (options.command === "summarize") {
252
+ const result = await runtimeDependencies.processSummary(
253
+ contextForLoadedSession(loaded),
254
+ {
255
+ model: runtime.model,
256
+ modelProvenance: runtime.modelProvenance,
257
+ summaryPolicy: runtime.summaryPolicy,
258
+ forceSynthesis: options.force === true,
259
+ allowExternalSession: true,
260
+ rarebitRoot: dependencies.rarebitRoot,
261
+ sessionRoot: dependencies.sessionRoot,
262
+ modelClient: {
263
+ complete: async ({ prompt }) => ({
264
+ text: await runtime.complete(prompt),
265
+ }),
266
+ },
267
+ },
268
+ );
269
+ return {
270
+ operation: "summary",
271
+ status: result.record.status,
272
+ duplicate: result.duplicate === true,
273
+ inFlight: result.inFlight === true,
274
+ record: result.record,
275
+ };
276
+ }
277
+
278
+ const result = await runtimeDependencies.processTitle(
279
+ contextForLoadedSession(loaded),
280
+ {
281
+ model: runtime.model,
282
+ modelProvenance: runtime.modelProvenance,
283
+ allowFirstUserFallback: true,
284
+ evidenceProvenance: "branch_user_fallback",
285
+ titleDate: sessionDate(loaded.session, options.date),
286
+ allowExternalSession: true,
287
+ rarebitRoot: dependencies.rarebitRoot,
288
+ sessionRoot: dependencies.sessionRoot,
289
+ modelClient: {
290
+ complete: async ({ prompt }) => ({
291
+ text: await runtime.complete(prompt),
292
+ }),
293
+ },
294
+ },
295
+ );
296
+ return {
297
+ operation: "title",
298
+ status: result.record.status,
299
+ duplicate: result.duplicate === true,
300
+ inFlight: result.inFlight === true,
301
+ session: loaded.session,
302
+ title: result.record.title,
303
+ record: result.record,
304
+ };
305
+ }
@@ -0,0 +1,281 @@
1
+ const literal = (value, description) => ({
2
+ kind: "literal",
3
+ value,
4
+ description,
5
+ });
6
+
7
+ const number = (usage, validate) => ({
8
+ kind: "number",
9
+ usage,
10
+ validate,
11
+ });
12
+
13
+ const rest = (usage) => ({
14
+ kind: "rest",
15
+ usage,
16
+ });
17
+
18
+ export const RAREBIT_COMMAND_GRAMMAR = Object.freeze({
19
+ name: "rarebit",
20
+ defaultSubcommand: "status",
21
+ subcommands: Object.freeze([
22
+ {
23
+ name: "status",
24
+ description: "Show effective Rarebit configuration",
25
+ forms: [[]],
26
+ },
27
+ {
28
+ name: "recall",
29
+ description: "Recall active-branch Rarebit messages with a prompt",
30
+ forms: [[rest("<prompt...>")]],
31
+ },
32
+ {
33
+ name: "config",
34
+ description: "Show or override summary policy for this process",
35
+ forms: [
36
+ [],
37
+ [
38
+ literal(
39
+ "max_rarebit_ratio",
40
+ "Override the maximum selected-prose ratio",
41
+ ),
42
+ number("<0..1>", (value) => value >= 0 && value <= 1),
43
+ ],
44
+ [
45
+ literal(
46
+ "min_total_length",
47
+ "Override the minimum estimated token count",
48
+ ),
49
+ number("<nonnegative estimated tokens>", (value) => value >= 0),
50
+ ],
51
+ ],
52
+ },
53
+ {
54
+ name: "auto-title",
55
+ description: "Show or override automatic Session titles",
56
+ forms: [
57
+ [],
58
+ [literal("on", "Enable automatic Session titles")],
59
+ [literal("off", "Disable automatic Session titles")],
60
+ ],
61
+ },
62
+ {
63
+ name: "title",
64
+ description: "Generate a title for the active Session",
65
+ usageNote: "use Pi's native /name for a literal title",
66
+ forms: [[]],
67
+ },
68
+ {
69
+ name: "summarize",
70
+ description: "Force a Rarebit Summary materialization",
71
+ forms: [[]],
72
+ },
73
+ ]),
74
+ });
75
+
76
+ const renderToken = (token) =>
77
+ token.kind === "literal" ? token.value : token.usage;
78
+
79
+ const renderForms = (forms) => {
80
+ const hasEmptyForm = forms.some((form) => form.length === 0);
81
+ const alternatives = forms
82
+ .filter((form) => form.length > 0)
83
+ .map((form) => form.map(renderToken).join(" "));
84
+ if (alternatives.length === 0) return "";
85
+ const rendered = alternatives.join("|");
86
+ return hasEmptyForm ? ` [${rendered}]` : ` ${rendered}`;
87
+ };
88
+
89
+ export function rarebitCommandDescription(grammar = RAREBIT_COMMAND_GRAMMAR) {
90
+ return `Rarebit ${grammar.subcommands.map(({ name }) => name).join("/")}`;
91
+ }
92
+
93
+ export function rarebitCommandUsage(
94
+ subcommand,
95
+ grammar = RAREBIT_COMMAND_GRAMMAR,
96
+ ) {
97
+ if (!subcommand) {
98
+ return `Usage: /${grammar.name} [${grammar.subcommands
99
+ .map(({ name }) => name)
100
+ .join("|")}]`;
101
+ }
102
+ const definition = grammar.subcommands.find(
103
+ ({ name }) => name === subcommand,
104
+ );
105
+ if (!definition) return rarebitCommandUsage(undefined, grammar);
106
+ const note = definition.usageNote ? ` (${definition.usageNote})` : "";
107
+ return `Usage: /${grammar.name} ${definition.name}${renderForms(definition.forms)}${note}`;
108
+ }
109
+
110
+ const matchToken = (definition, rawValue) => {
111
+ if (definition.kind === "literal") return rawValue === definition.value;
112
+ if (definition.kind === "rest") return String(rawValue).trim().length > 0;
113
+ const value = Number(rawValue);
114
+ return Number.isFinite(value) && definition.validate(value);
115
+ };
116
+
117
+ export function parseRarebitCommand(input, grammar = RAREBIT_COMMAND_GRAMMAR) {
118
+ const rawInput = String(input ?? "");
119
+ const tokens = rawInput.trim().split(/\s+/).filter(Boolean);
120
+ const subcommandName = tokens[0] ?? grammar.defaultSubcommand;
121
+ const definition = grammar.subcommands.find(
122
+ ({ name }) => name === subcommandName,
123
+ );
124
+ if (!definition) {
125
+ return {
126
+ ok: false,
127
+ error: "unknown_subcommand",
128
+ usage: rarebitCommandUsage(undefined, grammar),
129
+ };
130
+ }
131
+
132
+ const freeform = definition.forms.find(
133
+ (form) => form.at(-1)?.kind === "rest",
134
+ );
135
+ if (freeform) {
136
+ const prefix = freeform.slice(0, -1);
137
+ const leading = rawInput.trimStart();
138
+ const subcommandEnd = leading.search(/\s/);
139
+ const rawArguments = subcommandEnd < 0 ? "" : leading.slice(subcommandEnd);
140
+ let remaining = rawArguments;
141
+ const parsed = [];
142
+ let prefixMatches = true;
143
+ for (const token of prefix) {
144
+ const separator = remaining.match(/^\s+/)?.[0] ?? "";
145
+ remaining = remaining.slice(separator.length);
146
+ const value = remaining.match(/^\S+/)?.[0];
147
+ if (!value || !matchToken(token, value)) {
148
+ prefixMatches = false;
149
+ break;
150
+ }
151
+ parsed.push(value);
152
+ remaining = remaining.slice(value.length);
153
+ }
154
+ if (prefixMatches && /^\s/.test(remaining)) {
155
+ const prompt = remaining.slice(1);
156
+ if (matchToken(freeform.at(-1), prompt)) {
157
+ return {
158
+ ok: true,
159
+ subcommand: definition.name,
160
+ arguments: [...parsed, prompt],
161
+ };
162
+ }
163
+ }
164
+ return {
165
+ ok: false,
166
+ error: "invalid_arguments",
167
+ subcommand: definition.name,
168
+ usage: rarebitCommandUsage(definition.name, grammar),
169
+ };
170
+ }
171
+
172
+ const argumentTokens = tokens.slice(1);
173
+ const form = definition.forms.find(
174
+ (candidate) =>
175
+ candidate.length === argumentTokens.length &&
176
+ candidate.every((token, index) =>
177
+ matchToken(token, argumentTokens[index]),
178
+ ),
179
+ );
180
+ if (!form) {
181
+ const hasMatchingShape = definition.forms.some(
182
+ (candidate) =>
183
+ candidate.length === argumentTokens.length &&
184
+ candidate.every(
185
+ (token, index) =>
186
+ token.kind !== "literal" || token.value === argumentTokens[index],
187
+ ),
188
+ );
189
+ return {
190
+ ok: false,
191
+ error: hasMatchingShape ? "invalid_value" : "invalid_arguments",
192
+ subcommand: definition.name,
193
+ usage: rarebitCommandUsage(definition.name, grammar),
194
+ };
195
+ }
196
+
197
+ return {
198
+ ok: true,
199
+ subcommand: definition.name,
200
+ arguments: argumentTokens,
201
+ };
202
+ }
203
+
204
+ const completionItem = ({ value, label, description }) => ({
205
+ value,
206
+ label,
207
+ ...(description ? { description } : {}),
208
+ });
209
+
210
+ export function getRarebitArgumentCompletions(
211
+ prefix,
212
+ grammar = RAREBIT_COMMAND_GRAMMAR,
213
+ ) {
214
+ const input = String(prefix ?? "").trimStart();
215
+ const trailingWhitespace = /\s$/.test(input);
216
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
217
+
218
+ if (tokens.length === 0) {
219
+ return grammar.subcommands.map((subcommand) =>
220
+ completionItem({
221
+ value: subcommand.name,
222
+ label: subcommand.name,
223
+ description: subcommand.description,
224
+ }),
225
+ );
226
+ }
227
+
228
+ if (tokens.length === 1 && !trailingWhitespace) {
229
+ const partial = tokens[0];
230
+ const matches = grammar.subcommands
231
+ .filter(({ name }) => name.startsWith(partial))
232
+ .map((subcommand) =>
233
+ completionItem({
234
+ value: subcommand.name,
235
+ label: subcommand.name,
236
+ description: subcommand.description,
237
+ }),
238
+ );
239
+ return matches.length > 0 ? matches : null;
240
+ }
241
+
242
+ const definition = grammar.subcommands.find(({ name }) => name === tokens[0]);
243
+ if (!definition) return null;
244
+
245
+ const argumentTokens = tokens.slice(1);
246
+ const completionIndex = trailingWhitespace
247
+ ? argumentTokens.length
248
+ : argumentTokens.length - 1;
249
+ const partial = trailingWhitespace ? "" : argumentTokens.at(-1);
250
+ const priorArguments = argumentTokens.slice(0, completionIndex);
251
+ const matches = [];
252
+ const seen = new Set();
253
+
254
+ for (const form of definition.forms) {
255
+ const priorMatches = priorArguments.every(
256
+ (value, index) => form[index] && matchToken(form[index], value),
257
+ );
258
+ const token = form[completionIndex];
259
+ if (
260
+ !priorMatches ||
261
+ token?.kind !== "literal" ||
262
+ !token.value.startsWith(partial)
263
+ )
264
+ continue;
265
+
266
+ const needsFollowingArgument = form.length > completionIndex + 1;
267
+ const value = [definition.name, ...priorArguments, token.value].join(" ");
268
+ const completedValue = needsFollowingArgument ? `${value} ` : value;
269
+ if (seen.has(completedValue)) continue;
270
+ seen.add(completedValue);
271
+ matches.push(
272
+ completionItem({
273
+ value: completedValue,
274
+ label: token.value,
275
+ description: token.description,
276
+ }),
277
+ );
278
+ }
279
+
280
+ return matches.length > 0 ? matches : null;
281
+ }