@neruok/pi-common 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/commands.d.ts +26 -0
- package/dist/commands.js +117 -0
- package/dist/model-picker.d.ts +28 -0
- package/dist/model-picker.js +90 -0
- package/dist/model-types.d.ts +13 -0
- package/dist/model-types.js +2 -0
- package/dist/models.d.ts +6 -0
- package/dist/models.js +8 -0
- package/dist/presentation.d.ts +23 -0
- package/dist/presentation.js +22 -0
- package/dist/settings.d.ts +17 -0
- package/dist/settings.js +216 -0
- package/docs/pi-common.md +198 -0
- package/package.json +73 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody Wentz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @neruok/pi-common
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared behavior for Neruok Pi extensions. This package is a library, not an extension to install with `pi install`.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
|
|
7
|
+
| Import | Responsibility |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `@neruok/pi-common/model-picker` | Inline searchable model picker and RPC selection |
|
|
10
|
+
| `@neruok/pi-common/model-types` | Provider-free model types and reasoning values |
|
|
11
|
+
| `@neruok/pi-common/models` | Host-SDK reasoning capability checks |
|
|
12
|
+
| `@neruok/pi-common/settings` | Bounded reads, scoped layers, locked updates, and explicit replacement |
|
|
13
|
+
| `@neruok/pi-common/commands` | Scope parsing, autocomplete, and context invalidation |
|
|
14
|
+
| `@neruok/pi-common/presentation` | Text sanitization and UI/non-UI command reporting |
|
|
15
|
+
|
|
16
|
+
The package has no root export. Each extension imports the modules it needs.
|
|
17
|
+
The library does not register tools, change models, call providers, or start background resources.
|
|
18
|
+
Commands and model workflows remain in each extension.
|
|
19
|
+
|
|
20
|
+
## Settings contract
|
|
21
|
+
|
|
22
|
+
The settings module uses Node APIs only. Supply the existing byte limit and the extension schema.
|
|
23
|
+
Wrap each complete update or replacement in Pi's `withFileMutationQueue`.
|
|
24
|
+
The caller owns that queue. The library owns no process-global queue.
|
|
25
|
+
|
|
26
|
+
`updateJsonSettings` reads and validates JSON before it calls a synchronous update function.
|
|
27
|
+
Use it for scope-owned patches. Do not report, call models, or start asynchronous work inside the update function.
|
|
28
|
+
|
|
29
|
+
`replaceJsonSettings` replaces a regular file without decoding its old content.
|
|
30
|
+
Use it for explicit repair or reset. It compares streamed raw-byte fingerprints before replacement.
|
|
31
|
+
|
|
32
|
+
Writes acquire an exclusive lock. They use a synchronized same-directory temporary file.
|
|
33
|
+
Existing files use rename. First creation uses an exclusive hard link.
|
|
34
|
+
Changed checkpoints and lock contention fail without retry. The library does not reclaim stale locks.
|
|
35
|
+
Inspect a failed save before retry. A save can have completed before a cleanup or delivery failure.
|
|
36
|
+
|
|
37
|
+
Target and immediate-parent symlinks, special files, malformed UTF-8 reads, and oversized reads fail.
|
|
38
|
+
Parent-directory races and nonparticipating writers remain outside the guarantee.
|
|
39
|
+
These checks are not an OS sandbox or a power-loss durability guarantee.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
Requires Node.js 24 or newer. SDK-dependent modules use host Pi peers.
|
|
44
|
+
The settings, model-types, and presentation modules can load without Pi installed.
|
|
45
|
+
The picker and reporter accept narrow structural capabilities instead of complete session contexts.
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
npm ci --ignore-scripts
|
|
49
|
+
npm run verify
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Build this library before checking the consumer extensions.
|
|
53
|
+
The initial consumers are pi-advisor, pi-magic8ball, and pi-compaction-model.
|
|
54
|
+
Library verification does not require sibling consumer checkouts.
|
|
55
|
+
In the maintainer workspace, run `npm run check:consumers` to check all three packed consumers against their pinned Pi hosts.
|
|
56
|
+
Registry releases pin `@neruok/pi-common` to `0.1.0`. A consumer checkout does not need this repository.
|
|
57
|
+
Do not publish a consumer with a local file dependency.
|
|
58
|
+
|
|
59
|
+
## Compatibility and release
|
|
60
|
+
|
|
61
|
+
Requires Node.js 24 or newer. Checks cover Pi 1.0.4 and 1.1.0 on Linux.
|
|
62
|
+
CI tests both host versions. Other Pi versions and operating systems do not have release evidence here.
|
|
63
|
+
Host Pi packages remain optional wildcard peers, as Pi's packaging contract requires.
|
|
64
|
+
The library does not install or bundle host SDKs.
|
|
65
|
+
Exported shared constants are immutable. Command registration and session state remain consumer-owned.
|
|
66
|
+
|
|
67
|
+
The first release is `0.1.0`. Run `npm run verify` before publication.
|
|
68
|
+
The `prepublishOnly` gate runs the same checks and stops publication on failure.
|
|
69
|
+
The `prepack` gate builds the runtime modules and declarations.
|
|
70
|
+
Inspect the archive, then verify a clean npm installation before publication.
|
|
71
|
+
Publish the library before consumers. Verify registry availability and downloaded integrity before consumer installation checks.
|
|
72
|
+
There are no live-provider checks in `verify`.
|
|
73
|
+
The canonical contract is the workspace document `pi-common`. Its generated project output is `docs/pi-common.md`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AutocompleteItem } from '@earendil-works/pi-tui';
|
|
2
|
+
import { type PickerModel, type ModelSelection } from './model-types.js';
|
|
3
|
+
export interface Choice {
|
|
4
|
+
value: string;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
export type Scope = 'global' | 'project';
|
|
8
|
+
export declare const SCOPE_CHOICES: readonly Readonly<Choice>[];
|
|
9
|
+
export declare function scopedArguments(args: string): {
|
|
10
|
+
scope: Scope;
|
|
11
|
+
args: string[];
|
|
12
|
+
} | undefined;
|
|
13
|
+
export interface CompletionSpec {
|
|
14
|
+
commands: readonly Choice[];
|
|
15
|
+
modelCommands: readonly string[];
|
|
16
|
+
isolatedCommands?: Readonly<Record<string, readonly Choice[]>>;
|
|
17
|
+
models?: (command: string) => readonly PickerModel[] | Promise<readonly PickerModel[]>;
|
|
18
|
+
reasoning?: (project: boolean) => readonly Choice[] | Promise<readonly Choice[]>;
|
|
19
|
+
validateModel: (command: string, pair: ModelSelection) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function argumentCompletions(prefix: string, spec: CompletionSpec): Promise<AutocompleteItem[] | null>;
|
|
22
|
+
/** Each registration owns its context. Pending results cannot survive a context change. */
|
|
23
|
+
export declare function completionContext<T>(complete: (prefix: string, context?: T) => Promise<AutocompleteItem[] | null>): {
|
|
24
|
+
set(next?: T): void;
|
|
25
|
+
complete(prefix: string): Promise<AutocompleteItem[] | null>;
|
|
26
|
+
};
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { fuzzyFilter } from '@earendil-works/pi-tui';
|
|
2
|
+
import { REASONING_LEVELS } from './model-types.js';
|
|
3
|
+
export const SCOPE_CHOICES = Object.freeze([
|
|
4
|
+
Object.freeze({ value: '--global', description: 'Use global settings (default save scope)' }),
|
|
5
|
+
Object.freeze({ value: '--project', description: 'Use project settings (writes require trust)' }),
|
|
6
|
+
]);
|
|
7
|
+
export function scopedArguments(args) {
|
|
8
|
+
const tokens = args.trim() ? args.trim().split(/\s+/) : [];
|
|
9
|
+
const flags = tokens.filter(token => token.startsWith('--'));
|
|
10
|
+
if (flags.length > 1 || flags.some(flag => !SCOPE_CHOICES.some(choice => choice.value === flag)))
|
|
11
|
+
return undefined;
|
|
12
|
+
return { scope: flags[0] === '--project' ? 'project' : 'global', args: tokens.filter(token => !token.startsWith('--')) };
|
|
13
|
+
}
|
|
14
|
+
function isolatedChoices(spec, command) {
|
|
15
|
+
return spec.isolatedCommands && Object.hasOwn(spec.isolatedCommands, command) ? spec.isolatedCommands[command] : undefined;
|
|
16
|
+
}
|
|
17
|
+
function syntax(prefix, spec) {
|
|
18
|
+
const fragment = prefix.match(/\S*$/)[0];
|
|
19
|
+
const base = prefix.slice(0, prefix.length - fragment.length);
|
|
20
|
+
const parsed = scopedArguments(base);
|
|
21
|
+
if (!parsed)
|
|
22
|
+
return undefined;
|
|
23
|
+
const { args } = parsed;
|
|
24
|
+
const scoped = /(?:^|\s)--(?:global|project)(?:\s|$)/u.test(base);
|
|
25
|
+
const [command, argument] = args;
|
|
26
|
+
const isolated = isolatedChoices(spec, command);
|
|
27
|
+
if (isolated) {
|
|
28
|
+
if (scoped || args.length > 2 || (argument && !isolated.some(choice => choice.value === argument)))
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
else if (command === 'show') {
|
|
32
|
+
if (args.length > 1)
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
else if (command === 'reasoning') {
|
|
36
|
+
if (args.length > 2 || (argument && !REASONING_LEVELS.some(level => level === argument)))
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
else if (command === 'timeout') {
|
|
40
|
+
if (args.length > 2 || (argument && argument !== 'default' &&
|
|
41
|
+
(!/^[0-9]+$/.test(argument) || Number(argument) < 1 || Number(argument) > 2147483647)))
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
else if (spec.modelCommands.includes(command)) {
|
|
45
|
+
if (args.length > 3)
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
else if (command)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { base, fragment, args, scoped, project: parsed.scope === 'project' };
|
|
51
|
+
}
|
|
52
|
+
async function choices(input, spec) {
|
|
53
|
+
const { args, fragment, scoped, project } = input;
|
|
54
|
+
const [command, provider] = args;
|
|
55
|
+
const isolated = isolatedChoices(spec, command);
|
|
56
|
+
const scopes = scoped || isolated ? [] : SCOPE_CHOICES;
|
|
57
|
+
if (fragment.startsWith('--'))
|
|
58
|
+
return scopes;
|
|
59
|
+
if (!command)
|
|
60
|
+
return [...spec.commands.filter(choice => !scoped || !isolatedChoices(spec, choice.value)), ...scopes];
|
|
61
|
+
if (isolated)
|
|
62
|
+
return args.length === 1 ? isolated : [];
|
|
63
|
+
if (command === 'show' || ((command === 'reasoning' || command === 'timeout') && args.length === 2) || args.length === 3)
|
|
64
|
+
return scopes;
|
|
65
|
+
if (command === 'timeout')
|
|
66
|
+
return [{ value: 'default', description: 'Remove the scoped timeout and restore inheritance' }];
|
|
67
|
+
if (command === 'reasoning')
|
|
68
|
+
return await spec.reasoning?.(project) ?? [];
|
|
69
|
+
const models = await spec.models?.(command) ?? [];
|
|
70
|
+
if (!provider) {
|
|
71
|
+
return [...new Set(models.map(model => model.provider))].sort((a, b) => a.localeCompare(b))
|
|
72
|
+
.filter(value => value.toLowerCase().startsWith(fragment.toLowerCase()))
|
|
73
|
+
.map(value => ({ value, description: `Available ${command} provider` }));
|
|
74
|
+
}
|
|
75
|
+
const candidates = models.filter(model => model.provider === provider).sort((a, b) => a.id.localeCompare(b.id));
|
|
76
|
+
return fuzzyFilter(candidates, fragment, model => `${model.id} ${model.name ?? ''}`)
|
|
77
|
+
.map(model => ({ value: model.id, description: model.name || `Available ${command} model` }));
|
|
78
|
+
}
|
|
79
|
+
export async function argumentCompletions(prefix, spec) {
|
|
80
|
+
const input = syntax(prefix, spec);
|
|
81
|
+
if (!input)
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const catalog = spec.modelCommands.includes(input.args[0]) && input.args.length < 3 && !input.fragment.startsWith('--');
|
|
85
|
+
const candidates = await choices(input, spec);
|
|
86
|
+
const matched = catalog ? candidates : candidates.filter(choice => choice.value.startsWith(input.fragment));
|
|
87
|
+
const items = matched.filter(choice => {
|
|
88
|
+
if (!catalog)
|
|
89
|
+
return true;
|
|
90
|
+
if (choice.value.startsWith('--'))
|
|
91
|
+
return false;
|
|
92
|
+
try {
|
|
93
|
+
spec.validateModel(input.args[0], { provider: input.args[1] ?? choice.value, model: input.args[1] ? choice.value : 'placeholder' });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}).map(choice => ({ value: `${input.base}${choice.value} `, label: choice.value, description: choice.description }));
|
|
100
|
+
return items.length ? items : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Each registration owns its context. Pending results cannot survive a context change. */
|
|
107
|
+
export function completionContext(complete) {
|
|
108
|
+
let context, revision = 0;
|
|
109
|
+
return {
|
|
110
|
+
set(next) { context = next; revision++; },
|
|
111
|
+
async complete(prefix) {
|
|
112
|
+
const before = revision;
|
|
113
|
+
const items = await complete(prefix, context);
|
|
114
|
+
return before === revision ? items : null;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ModelSelection as Selection, PickerModel } from './model-types.js';
|
|
2
|
+
export declare class ModelPickerError extends Error {
|
|
3
|
+
}
|
|
4
|
+
interface PickerComponent {
|
|
5
|
+
focused: boolean;
|
|
6
|
+
render(width: number): string[];
|
|
7
|
+
invalidate(): void;
|
|
8
|
+
handleInput(data: string): void;
|
|
9
|
+
}
|
|
10
|
+
interface PickerTheme {
|
|
11
|
+
fg(color: 'accent' | 'muted' | 'dim', text: string): string;
|
|
12
|
+
bold(text: string): string;
|
|
13
|
+
}
|
|
14
|
+
export interface ModelPickerContext {
|
|
15
|
+
mode: string;
|
|
16
|
+
ui: {
|
|
17
|
+
select(title: string, choices: string[]): Promise<string | undefined>;
|
|
18
|
+
custom<T>(factory: (tui: {
|
|
19
|
+
terminal: {
|
|
20
|
+
rows: number;
|
|
21
|
+
};
|
|
22
|
+
requestRender(): void;
|
|
23
|
+
}, theme: PickerTheme, keybindings: unknown, done: (value: T) => void) => PickerComponent): Promise<T>;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Shared inline picker. The caller supplies the eligible model snapshot. */
|
|
27
|
+
export declare function selectModel(ctx: ModelPickerContext, title: string, models: readonly PickerModel[], current?: Selection): Promise<Selection | undefined>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fuzzyFilter, getKeybindings, Input, SelectList, truncateToWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
import { sanitizeLabel } from './presentation.js';
|
|
3
|
+
export class ModelPickerError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const key = (model) => `${model.provider} / ${model.id}`;
|
|
6
|
+
/** Shared inline picker. The caller supplies the eligible model snapshot. */
|
|
7
|
+
export async function selectModel(ctx, title, models, current) {
|
|
8
|
+
const entries = new Map(models.map(model => [key(model), model]));
|
|
9
|
+
if (ctx.mode !== 'tui') {
|
|
10
|
+
// Use the same provider/model labels in every RPC client.
|
|
11
|
+
const choices = new Map(models.map(model => [`${model.provider}/${model.id}`, model]));
|
|
12
|
+
const selected = await ctx.ui.select(title, [...choices.keys()]);
|
|
13
|
+
if (selected === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
const model = choices.get(selected);
|
|
16
|
+
if (!model)
|
|
17
|
+
throw new ModelPickerError('Model unavailable.');
|
|
18
|
+
return { provider: model.provider, model: model.id };
|
|
19
|
+
}
|
|
20
|
+
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
21
|
+
const isCurrent = (model) => model.provider === current?.provider && model.id === current.model;
|
|
22
|
+
const items = [...entries.values()]
|
|
23
|
+
.sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || a.provider.localeCompare(b.provider))
|
|
24
|
+
.map(model => ({ value: key(model), label: `${isCurrent(model) ? '✓ ' : ' '}${sanitizeLabel(model.id)} [${sanitizeLabel(model.provider)}]`, model }));
|
|
25
|
+
const search = new Input();
|
|
26
|
+
const style = {
|
|
27
|
+
selectedPrefix: (text) => theme.fg('accent', text),
|
|
28
|
+
selectedText: (text) => theme.fg('accent', text),
|
|
29
|
+
description: (text) => theme.fg('muted', text),
|
|
30
|
+
scrollInfo: (text) => theme.fg('muted', text),
|
|
31
|
+
noMatch: () => theme.fg('muted', ' No matching models'),
|
|
32
|
+
};
|
|
33
|
+
let filtered = items, selectedIndex = 0, visible = 10;
|
|
34
|
+
function makeList() {
|
|
35
|
+
const next = new SelectList(filtered, visible, style);
|
|
36
|
+
next.setSelectedIndex(selectedIndex);
|
|
37
|
+
next.onSelectionChange = item => { selectedIndex = filtered.findIndex(entry => entry.value === item.value); };
|
|
38
|
+
next.onSelect = item => {
|
|
39
|
+
const model = entries.get(item.value);
|
|
40
|
+
done({ provider: model.provider, model: model.id });
|
|
41
|
+
};
|
|
42
|
+
next.onCancel = () => done(undefined);
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
let list = makeList();
|
|
46
|
+
search.onSubmit = () => list.handleInput('\r');
|
|
47
|
+
function filter(query) {
|
|
48
|
+
// Same search text order and token ranking as Pi's /model selector.
|
|
49
|
+
filtered = fuzzyFilter(items, query, ({ model }) => `${model.provider} ${model.provider}/${model.id} ${model.provider} ${model.id}${model.name ? ` ${model.name}` : ''}`);
|
|
50
|
+
selectedIndex = query ? 0 : Math.min(selectedIndex, Math.max(0, filtered.length - 1));
|
|
51
|
+
list = makeList();
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
get focused() { return search.focused; },
|
|
55
|
+
set focused(value) { search.focused = value; },
|
|
56
|
+
render(width) {
|
|
57
|
+
const height = Math.max(1, tui.terminal.rows - 2);
|
|
58
|
+
const full = height >= 10;
|
|
59
|
+
const border = theme.fg('accent', '─'.repeat(Math.max(0, width)));
|
|
60
|
+
const heading = theme.fg('accent', theme.bold(sanitizeLabel(title)));
|
|
61
|
+
const prefix = full ? [border, heading, ...search.render(width), '']
|
|
62
|
+
: height >= 4 ? [heading, ...search.render(width)] : height >= 2 ? search.render(width) : [];
|
|
63
|
+
const selected = filtered[selectedIndex]?.model;
|
|
64
|
+
const suffix = full ? ['', theme.fg('muted', ` Model Name: ${sanitizeLabel(selected?.name ?? selected?.id)}`), theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), border] : [];
|
|
65
|
+
const listHeight = height - prefix.length - suffix.length;
|
|
66
|
+
// Count SelectList's position indicator within the viewport budget.
|
|
67
|
+
const count = Math.max(1, Math.min(10, filtered.length, listHeight - (filtered.length > Math.min(10, listHeight) ? 1 : 0)));
|
|
68
|
+
if (count !== visible) {
|
|
69
|
+
visible = count;
|
|
70
|
+
list = makeList();
|
|
71
|
+
}
|
|
72
|
+
return [...prefix, ...list.render(width).slice(0, listHeight), ...suffix].map(line => truncateToWidth(line, width));
|
|
73
|
+
},
|
|
74
|
+
invalidate() { search.invalidate(); list.invalidate(); },
|
|
75
|
+
handleInput(data) {
|
|
76
|
+
const kb = getKeybindings();
|
|
77
|
+
if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down') || kb.matches(data, 'tui.select.confirm') || kb.matches(data, 'tui.select.cancel')) {
|
|
78
|
+
list.handleInput(data);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const before = search.getValue();
|
|
82
|
+
search.handleInput(data);
|
|
83
|
+
if (search.getValue() !== before)
|
|
84
|
+
filter(search.getValue());
|
|
85
|
+
}
|
|
86
|
+
tui.requestRender();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Data-only types and values. Safe for provider-free cores and standalone scripts. */
|
|
2
|
+
export declare const REASONING_LEVELS: readonly ["default", "off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
3
|
+
export type Reasoning = typeof REASONING_LEVELS[number];
|
|
4
|
+
export type ModelSelection = {
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
reasoning?: Reasoning;
|
|
8
|
+
};
|
|
9
|
+
export type PickerModel = {
|
|
10
|
+
provider: string;
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
};
|
package/dist/models.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Api, type Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { Reasoning } from './model-types.js';
|
|
3
|
+
export { REASONING_LEVELS } from './model-types.js';
|
|
4
|
+
export type { Reasoning, ModelSelection, PickerModel } from './model-types.js';
|
|
5
|
+
export declare function supportedReasoning(model: Model<Api>): Reasoning[];
|
|
6
|
+
export declare function supportsReasoning(model: Model<Api>, level?: Reasoning): boolean;
|
package/dist/models.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
2
|
+
export { REASONING_LEVELS } from './model-types.js';
|
|
3
|
+
export function supportedReasoning(model) {
|
|
4
|
+
return ['default', ...getSupportedThinkingLevels(model)];
|
|
5
|
+
}
|
|
6
|
+
export function supportsReasoning(model, level) {
|
|
7
|
+
return level === undefined || supportedReasoning(model).includes(level);
|
|
8
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface ReportHost {
|
|
2
|
+
sendMessage(message: {
|
|
3
|
+
customType: string;
|
|
4
|
+
content: string;
|
|
5
|
+
display: boolean;
|
|
6
|
+
details: {
|
|
7
|
+
level: 'info' | 'error';
|
|
8
|
+
};
|
|
9
|
+
}, options: {
|
|
10
|
+
triggerTurn: false;
|
|
11
|
+
}): void;
|
|
12
|
+
}
|
|
13
|
+
export interface ReportContext {
|
|
14
|
+
hasUI: boolean;
|
|
15
|
+
ui: {
|
|
16
|
+
notify(text: string, level: 'info' | 'error'): void;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Sanitize data before theme styling. Preserve multiline text, not terminal controls. */
|
|
20
|
+
export declare function sanitizeText(text: string): string;
|
|
21
|
+
export declare function sanitizeLabel(value: unknown): string;
|
|
22
|
+
/** Reporting never starts a turn or changes the operation's outcome. */
|
|
23
|
+
export declare function reportCommand(pi: ReportHost, ctx: ReportContext, customType: string, content: string, level: 'info' | 'error'): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Sanitize data before theme styling. Preserve multiline text, not terminal controls. */
|
|
2
|
+
export function sanitizeText(text) {
|
|
3
|
+
return text.replace(/\t/g, ' ').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/gu, '');
|
|
4
|
+
}
|
|
5
|
+
export function sanitizeLabel(value) {
|
|
6
|
+
return sanitizeText(String(value ?? '')).replace(/\n/g, ' ');
|
|
7
|
+
}
|
|
8
|
+
/** Reporting never starts a turn or changes the operation's outcome. */
|
|
9
|
+
export function reportCommand(pi, ctx, customType, content, level) {
|
|
10
|
+
try {
|
|
11
|
+
const text = sanitizeText(content);
|
|
12
|
+
if (ctx.hasUI)
|
|
13
|
+
ctx.ui.notify(text, level);
|
|
14
|
+
else
|
|
15
|
+
pi.sendMessage({ customType, content: text, display: true, details: { level } }, { triggerTurn: false });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
console.error(`${sanitizeLabel(customType)}: could not report the result.`);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class SettingsError extends Error {
|
|
2
|
+
readonly code: 'invalid-config' | 'settings-unavailable';
|
|
3
|
+
constructor(code: 'invalid-config' | 'settings-unavailable');
|
|
4
|
+
}
|
|
5
|
+
export declare function readSettingsBytes(path: string, maxBytes: number): Promise<Buffer | undefined>;
|
|
6
|
+
export type Scope = 'global' | 'project';
|
|
7
|
+
export type SettingsPaths = Record<Scope, string>;
|
|
8
|
+
export declare function settingsPaths(cwd: string, agentDir: string, filename: string): SettingsPaths;
|
|
9
|
+
export declare function loadScopedSettings<T extends object>(paths: SettingsPaths, trusted: boolean, maxBytes: number, parse: (value: unknown) => T): Promise<{
|
|
10
|
+
settings: T;
|
|
11
|
+
sources: Partial<Record<keyof T, Scope>>;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function decodeSettings(raw: Buffer | undefined): unknown;
|
|
14
|
+
/** Update only valid JSON. The synchronous callback supplies schema and merge policy. */
|
|
15
|
+
export declare function updateJsonSettings(path: string, maxBytes: number, update: (value: unknown) => unknown): Promise<void>;
|
|
16
|
+
/** Explicit replacement repairs malformed regular files. It never follows symlinks. */
|
|
17
|
+
export declare function replaceJsonSettings(path: string, value: unknown, maxBytes: number): Promise<void>;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, mkdir, rename, link, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
export class SettingsError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code) {
|
|
8
|
+
super(code);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const missing = (error) => Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT');
|
|
13
|
+
function unavailable(error) {
|
|
14
|
+
throw error instanceof SettingsError ? error : new SettingsError('settings-unavailable');
|
|
15
|
+
}
|
|
16
|
+
function bound(maxBytes) {
|
|
17
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
18
|
+
throw new SettingsError('invalid-config');
|
|
19
|
+
}
|
|
20
|
+
async function checkParent(path) {
|
|
21
|
+
try {
|
|
22
|
+
const stat = await lstat(dirname(path));
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
24
|
+
throw new SettingsError('settings-unavailable');
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!missing(error))
|
|
28
|
+
unavailable(error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function regularFile(path) {
|
|
32
|
+
await checkParent(path);
|
|
33
|
+
try {
|
|
34
|
+
const stat = await lstat(path);
|
|
35
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
36
|
+
throw new SettingsError('settings-unavailable');
|
|
37
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
38
|
+
try {
|
|
39
|
+
if (!(await file.stat()).isFile())
|
|
40
|
+
throw new SettingsError('settings-unavailable');
|
|
41
|
+
return file;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
await file.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (missing(error))
|
|
50
|
+
return undefined;
|
|
51
|
+
return unavailable(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function readSettingsBytes(path, maxBytes) {
|
|
55
|
+
bound(maxBytes);
|
|
56
|
+
const file = await regularFile(path);
|
|
57
|
+
if (!file)
|
|
58
|
+
return undefined;
|
|
59
|
+
try {
|
|
60
|
+
if ((await file.stat()).size > maxBytes)
|
|
61
|
+
throw new SettingsError('invalid-config');
|
|
62
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
63
|
+
let length = 0;
|
|
64
|
+
while (length < buffer.length) {
|
|
65
|
+
const { bytesRead } = await file.read(buffer, length, buffer.length - length, length);
|
|
66
|
+
if (!bytesRead)
|
|
67
|
+
break;
|
|
68
|
+
length += bytesRead;
|
|
69
|
+
}
|
|
70
|
+
if (length > maxBytes)
|
|
71
|
+
throw new SettingsError('invalid-config');
|
|
72
|
+
return buffer.subarray(0, length);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return unavailable(error);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await file.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function settingsPaths(cwd, agentDir, filename) {
|
|
82
|
+
return { global: join(agentDir, filename), project: join(cwd, '.pi', filename) };
|
|
83
|
+
}
|
|
84
|
+
export async function loadScopedSettings(paths, trusted, maxBytes, parse) {
|
|
85
|
+
const read = async (path) => {
|
|
86
|
+
const value = decodeSettings(await readSettingsBytes(path, maxBytes));
|
|
87
|
+
try {
|
|
88
|
+
return parse(value);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new SettingsError('invalid-config');
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const global = await read(paths.global);
|
|
95
|
+
const project = trusted ? await read(paths.project) : {};
|
|
96
|
+
const settings = { ...global, ...project };
|
|
97
|
+
const sources = {};
|
|
98
|
+
for (const key of Object.keys(settings))
|
|
99
|
+
sources[key] = Object.hasOwn(project, key) ? 'project' : 'global';
|
|
100
|
+
return { settings, sources };
|
|
101
|
+
}
|
|
102
|
+
export function decodeSettings(raw) {
|
|
103
|
+
if (raw === undefined)
|
|
104
|
+
return {};
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new SettingsError('invalid-config');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Raw repair checkpoints do not decode or retain the complete old file. */
|
|
113
|
+
async function fingerprint(path) {
|
|
114
|
+
const file = await regularFile(path);
|
|
115
|
+
if (!file)
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const hash = createHash('sha256'), chunk = Buffer.alloc(65536);
|
|
119
|
+
while (true) {
|
|
120
|
+
const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
|
|
121
|
+
if (!bytesRead)
|
|
122
|
+
break;
|
|
123
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
124
|
+
}
|
|
125
|
+
return hash.digest('hex');
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await file.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function serialize(value, maxBytes) {
|
|
132
|
+
bound(maxBytes);
|
|
133
|
+
const json = JSON.stringify(value, null, 2);
|
|
134
|
+
if (json === undefined)
|
|
135
|
+
throw new SettingsError('invalid-config');
|
|
136
|
+
const text = json + '\n';
|
|
137
|
+
if (Buffer.byteLength(text) > maxBytes)
|
|
138
|
+
throw new SettingsError('invalid-config');
|
|
139
|
+
return text;
|
|
140
|
+
}
|
|
141
|
+
/** Caller owns the host queue. The exclusive lock covers read, update, and commit. */
|
|
142
|
+
async function transaction(path, prepare) {
|
|
143
|
+
let lock, ownedTemp = false;
|
|
144
|
+
const lockPath = path + '.lock', temp = path + '.' + randomUUID() + '.tmp';
|
|
145
|
+
try {
|
|
146
|
+
await checkParent(path);
|
|
147
|
+
await mkdir(dirname(path), { recursive: true });
|
|
148
|
+
await checkParent(path);
|
|
149
|
+
lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
150
|
+
const checkpoint = await prepare();
|
|
151
|
+
const output = await open(temp, 'wx', 0o600);
|
|
152
|
+
ownedTemp = true;
|
|
153
|
+
try {
|
|
154
|
+
await output.writeFile(checkpoint.text, 'utf8');
|
|
155
|
+
await output.sync();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await output.close();
|
|
159
|
+
}
|
|
160
|
+
if (!(await checkpoint.unchanged()))
|
|
161
|
+
throw new SettingsError('settings-unavailable');
|
|
162
|
+
if (checkpoint.exists)
|
|
163
|
+
await rename(temp, path);
|
|
164
|
+
else
|
|
165
|
+
await link(temp, path);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
unavailable(error);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
try {
|
|
172
|
+
if (ownedTemp) {
|
|
173
|
+
try {
|
|
174
|
+
await unlink(temp);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (!missing(error))
|
|
178
|
+
unavailable(error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
if (lock) {
|
|
184
|
+
try {
|
|
185
|
+
const owned = await lock.stat(), current = await lstat(lockPath);
|
|
186
|
+
if (owned.ino !== current.ino || owned.dev !== current.dev || current.isSymbolicLink())
|
|
187
|
+
throw new SettingsError('settings-unavailable');
|
|
188
|
+
await unlink(lockPath);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await lock.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Update only valid JSON. The synchronous callback supplies schema and merge policy. */
|
|
198
|
+
export async function updateJsonSettings(path, maxBytes, update) {
|
|
199
|
+
bound(maxBytes);
|
|
200
|
+
await transaction(path, async () => {
|
|
201
|
+
const before = await readSettingsBytes(path, maxBytes);
|
|
202
|
+
const text = serialize(update(decodeSettings(before)), maxBytes);
|
|
203
|
+
return { text, exists: before !== undefined, unchanged: async () => {
|
|
204
|
+
const current = await readSettingsBytes(path, maxBytes);
|
|
205
|
+
return before === undefined ? current === undefined : current !== undefined && current.equals(before);
|
|
206
|
+
} };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Explicit replacement repairs malformed regular files. It never follows symlinks. */
|
|
210
|
+
export async function replaceJsonSettings(path, value, maxBytes) {
|
|
211
|
+
const text = serialize(value, maxBytes);
|
|
212
|
+
await transaction(path, async () => {
|
|
213
|
+
const before = await fingerprint(path);
|
|
214
|
+
return { text, exists: before !== undefined, unchanged: async () => (await fingerprint(path)) === before };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<!-- generated by workspace-docs. source: pi-common revision 6. do not edit; author with workspace-docs checkout. -->
|
|
2
|
+
|
|
3
|
+
# Pi common: shared extension behavior
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Scope](#scope)
|
|
8
|
+
- [Contract](#contract)
|
|
9
|
+
- [Acceptance](#acceptance)
|
|
10
|
+
- [Remaining scope](#remaining-scope)
|
|
11
|
+
- [Initial implementation and verification](#implementation)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Scope {#scope}
|
|
15
|
+
|
|
16
|
+
The user requested shared extension behavior, not only code deduplication. The first consumers are advisor, magic8ball, and compaction-model.
|
|
17
|
+
|
|
18
|
+
The library owns model pickers, reasoning discovery, settings transactions, completion mechanics, command reporting, and text sanitization.
|
|
19
|
+
Extensions retain domain workflows, model eligibility, schemas, paths, scope rules, request budgets, authentication, and response envelopes.
|
|
20
|
+
The documentation, file-memory, and llama-swap cores retain their separate storage and provider contracts.
|
|
21
|
+
|
|
22
|
+
## Contract {#contract}
|
|
23
|
+
|
|
24
|
+
<a id="CC-REQ-1"></a>
|
|
25
|
+
|
|
26
|
+
**CC-REQ-1**
|
|
27
|
+
|
|
28
|
+
The package MUST be a compiled ESM library named @neruok/pi-common, with explicit typed subpath exports.
|
|
29
|
+
It MUST NOT register extensions, start background resources, call models, or own a mutable singleton.
|
|
30
|
+
Host Pi packages MUST be optional peers, never bundled runtime dependencies.
|
|
31
|
+
The settings subpath MUST load without Pi packages. Callers MUST provide Pi's mutation queue around each complete transaction.
|
|
32
|
+
Local migrations MUST use declared file dependencies until a separately authorized release provides registry dependencies.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
<a id="CC-REQ-2"></a>
|
|
36
|
+
|
|
37
|
+
**CC-REQ-2**
|
|
38
|
+
|
|
39
|
+
All three model extensions MUST use one model picker and one reasoning-discovery implementation.
|
|
40
|
+
The picker MUST preserve current-first ordering, fuzzy ranking, focus forwarding, ten-row limits, resize handling, and cancellation.
|
|
41
|
+
RPC model labels MUST use provider/model in all three extensions. Returned identifiers MUST remain complete and unchanged.
|
|
42
|
+
Reasoning choices MUST be default followed by Pi getSupportedThinkingLevels. Unsupported explicit effort MUST NOT clamp or fall back.
|
|
43
|
+
Domain errors, model eligibility, and request-option handling MUST remain extension-owned.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
<a id="CC-REQ-3"></a>
|
|
47
|
+
|
|
48
|
+
**CC-REQ-3**
|
|
49
|
+
|
|
50
|
+
Settings reads MUST reject target and immediate-parent symlinks, special files, invalid UTF-8, and oversized content.
|
|
51
|
+
Readers MUST enforce the caller's existing inclusive bound: 16000 bytes for magic8ball and 16384 for advisor and compaction-model.
|
|
52
|
+
Each settings write MUST use an exclusive lock, an owned same-directory temporary file, file synchronization, and a final checkpoint comparison.
|
|
53
|
+
Existing files MUST use atomic rename. First creation MUST use exclusive link without replacing a newly appeared file.
|
|
54
|
+
Lock contention or a changed checkpoint MUST fail without a retry or stale-lock reclamation.
|
|
55
|
+
Cleanup MUST remove only owned temporary files and locks whose inode and device still match.
|
|
56
|
+
A replacement operation MUST preserve compaction's repair of malformed regular files without JSON or UTF-8 decoding.
|
|
57
|
+
Replacement checkpoints MUST stream raw bytes, so malformed or oversized old files do not require whole-file buffering.
|
|
58
|
+
These checks MUST NOT claim an OS sandbox, power-loss durability, or containment of nonparticipating writers and parent-directory races.
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
<a id="CC-REQ-4"></a>
|
|
62
|
+
|
|
63
|
+
**CC-REQ-4**
|
|
64
|
+
|
|
65
|
+
Advisor and magic8ball MUST use common completion tokenization, scope validation, prefix replacement, and catalog ranking.
|
|
66
|
+
Static completion MUST work without settings or catalog access. Dynamic failures MUST remain silent and return no suggestions.
|
|
67
|
+
A changed session context MUST invalidate pending completion results.
|
|
68
|
+
Existing command grammar, trust checks, supported model types, and transcript rules MUST remain unchanged.
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
<a id="CC-REQ-5"></a>
|
|
72
|
+
|
|
73
|
+
**CC-REQ-5**
|
|
74
|
+
|
|
75
|
+
All three model extensions MUST use one reporter for settings commands and safe outcome notices.
|
|
76
|
+
With UI, it MUST notify. Without UI, it MUST send a visible custom message with triggerTurn false.
|
|
77
|
+
Reporting failure MUST use fixed local fallback text without exposing exceptions or starting a model turn.
|
|
78
|
+
Shared text sanitizers MUST remove terminal controls and bidirectional controls before styling.
|
|
79
|
+
Multiline text MUST preserve line breaks. Single-line labels MUST flatten line breaks.
|
|
80
|
+
Domain result layouts and usage accounting MUST remain unchanged.
|
|
81
|
+
Transcript viewing MUST retain its existing UI-only delivery. It MUST NOT copy captured transcript text into custom session messages.
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
<a id="CC-REQ-6"></a>
|
|
85
|
+
|
|
86
|
+
**CC-REQ-6**
|
|
87
|
+
|
|
88
|
+
The first registry release MUST be @neruok/pi-common@0.1.0 with public npm access and repository metadata.
|
|
89
|
+
The library MUST keep host Pi packages as optional wildcard peers. CI MUST verify Node 24 with Pi 1.0.4 and 1.1.0.
|
|
90
|
+
The publication lifecycle MUST run independent library verification and stop on verification failure.
|
|
91
|
+
Consumers MUST pin 0.1.0 exactly and use registry lockfiles before their release. They MUST NOT publish local file dependencies.
|
|
92
|
+
Release validation MUST install the library archive through npm in isolation, then test consumer archives through Pi's real package manager.
|
|
93
|
+
The release MUST NOT call model providers. Source, packaged bytes, downloaded registry integrity, and GitHub commits MUST agree.
|
|
94
|
+
After any unknown publication outcome, inspect registry state before another publication attempt.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## Acceptance {#acceptance}
|
|
98
|
+
|
|
99
|
+
<a id="CC-AC-1"></a>
|
|
100
|
+
|
|
101
|
+
**CC-AC-1**
|
|
102
|
+
|
|
103
|
+
Changed behavior. Before migration, dependency checks fail because the three consumers do not declare pi-common.
|
|
104
|
+
After migration, all three declare the library. Packed-library tests verify typed exports, import restrictions, and pure settings loading.
|
|
105
|
+
Each packed consumer loads through its installed Pi resource loader with the packed common dependency in node_modules.
|
|
106
|
+
No sibling-source symlink or bundled Pi package may satisfy the archive checks.
|
|
107
|
+
Changed behavior during release preparation. An immutable-constants check fails for the exported scope choices before freezing.
|
|
108
|
+
After freezing, mutation of either the shared array or its entries throws without changing command behavior.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
<a id="CC-AC-2"></a>
|
|
112
|
+
|
|
113
|
+
**CC-AC-2**
|
|
114
|
+
|
|
115
|
+
Preserved behavior. Existing advisor and compaction picker tests pass before and after migration.
|
|
116
|
+
Changed behavior. A magic8ball RPC picker test fails before migration because labels contain spaces around the slash.
|
|
117
|
+
After migration, all three use provider/model labels, preserve full identifiers, and cancel without writes or model calls.
|
|
118
|
+
Shared picker tests cover search, no-match Enter, focus, narrow dimensions, wrapping navigation, and Unicode identifiers.
|
|
119
|
+
Reasoning support tests compare the shared result with the public SDK and reject unsupported levels without provider work.
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
<a id="CC-AC-3"></a>
|
|
123
|
+
|
|
124
|
+
**CC-AC-3**
|
|
125
|
+
|
|
126
|
+
Preserved behavior. Existing settings tests pass before and after migration, including trust, limits, malformed files, and reset repair.
|
|
127
|
+
Changed behavior. Compaction lock and immediate-parent-symlink tests fail before migration and pass afterward.
|
|
128
|
+
Shared transaction tests verify exact byte bounds, malformed UTF-8 refusal, special-file refusal, concurrent checkpoint changes, and owned cleanup.
|
|
129
|
+
Replacement tests repair invalid UTF-8 and oversized old regular files, retain lock refusal, and refuse symlink targets.
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
<a id="CC-AC-4"></a>
|
|
133
|
+
|
|
134
|
+
**CC-AC-4**
|
|
135
|
+
|
|
136
|
+
Preserved behavior. Existing advisor and magic8ball autocomplete suites pass before and after migration.
|
|
137
|
+
Shared checks cover repeated/conflicting scopes, preserved whitespace, sorted providers, fuzzy model ranking, silent lookup failure, and stale-context rejection.
|
|
138
|
+
Neither completion nor command reporting may write settings or perform model work.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
<a id="CC-AC-5"></a>
|
|
142
|
+
|
|
143
|
+
**CC-AC-5**
|
|
144
|
+
|
|
145
|
+
Changed behavior. Magic8ball no-UI command tests fail before migration because reports only call ui.notify.
|
|
146
|
+
After migration, show, reasoning, timeout, invalid syntax, cancellation, and save results use a visible custom message without a model turn.
|
|
147
|
+
Shared reporter checks cover UI notification, non-UI delivery, and a failed delivery with fixed fallback text.
|
|
148
|
+
A magic8ball renderer check fails before migration for bidirectional controls and passes with common sanitization.
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
- #CC-REQ-1 verified-by: [#CC-AC-1](#CC-AC-1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
- #CC-REQ-2 verified-by: [#CC-AC-2](#CC-AC-2)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
- #CC-REQ-3 verified-by: [#CC-AC-3](#CC-AC-3)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
- #CC-REQ-4 verified-by: [#CC-AC-4](#CC-AC-4)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
- #CC-REQ-5 verified-by: [#CC-AC-5](#CC-AC-5)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
<a id="CC-AC-6"></a>
|
|
167
|
+
|
|
168
|
+
**CC-AC-6**
|
|
169
|
+
|
|
170
|
+
Changed behavior. Before release preparation, manifest checks fail for private publication, missing public metadata, and a missing prepublishOnly gate.
|
|
171
|
+
After preparation, the manifest is public, declares the repository, and has a verification gate. A lifecycle fixture proves success propagation and failure refusal.
|
|
172
|
+
A clean npm install of the library tarball exposes all declared runtime and type exports without installing host SDK copies.
|
|
173
|
+
Each consumer's release manifest pins common 0.1.0. Clean Pi installations resolve that registry dependency without a sibling checkout.
|
|
174
|
+
The installed extensions load under their tested Pi host, register their expected resources, and execute safe show/cancellation checks without provider calls.
|
|
175
|
+
Published version, dependency metadata, dist integrity, and remote commit identities must match the inspected release artifacts.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## Remaining scope {#remaining-scope}
|
|
179
|
+
|
|
180
|
+
The first migration does not change command names, compaction scope, request deadlines, retention, or result schemas.
|
|
181
|
+
Further unification of those public contracts requires explicit decisions and acceptance checks.
|
|
182
|
+
The user authorized release preparation, repository creation, commits, pushes, and npm publication for this library and its three initial consumers.
|
|
183
|
+
Live-provider tests remain outside this release.
|
|
184
|
+
|
|
185
|
+
## Initial implementation and verification {#implementation}
|
|
186
|
+
|
|
187
|
+
The library exports model-picker, model-types, models, settings, commands, and presentation.
|
|
188
|
+
The model-types module contains provider-free constants. Standalone benchmark scripts do not load Pi through those constants.
|
|
189
|
+
Picker and reporter interfaces expose only the structural capabilities they use. They do not expose complete Pi session contexts.
|
|
190
|
+
|
|
191
|
+
Changed checks failed before migration for missing dependencies, RPC label spacing, no-UI reports, foreign locks, and parent symlinks.
|
|
192
|
+
The preserved picker, settings, reasoning, completion, and reset checks passed before migration.
|
|
193
|
+
Packed dependency checks load actual archives without sibling-source symlinks or bundled host packages.
|
|
194
|
+
Release preparation added public metadata, verification gates, an npm archive-install check, and immutable command constants.
|
|
195
|
+
CI checks Node 24 with Pi 1.0.4 and 1.1.0. Local registry migrations used file dependencies before the authorized release.
|
|
196
|
+
Uniform command names, compaction project scope, deadlines, retention, and domain result layouts remain outside this first migration.
|
|
197
|
+
|
|
198
|
+
- #CC-REQ-6 verified-by: [#CC-AC-6](#CC-AC-6)
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neruok/pi-common",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
5
|
+
"repository": { "type": "git", "url": "https://github.com/neruok/pi-common.git" },
|
|
6
|
+
"homepage": "https://github.com/neruok/pi-common#readme",
|
|
7
|
+
"bugs": { "url": "https://github.com/neruok/pi-common/issues" },
|
|
8
|
+
"type": "module",
|
|
9
|
+
"description": "Shared behavior for Neruok Pi extensions",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
"./model-types": { "types": "./dist/model-types.d.ts", "import": "./dist/model-types.js" },
|
|
16
|
+
"./model-picker": {
|
|
17
|
+
"types": "./dist/model-picker.d.ts",
|
|
18
|
+
"import": "./dist/model-picker.js"
|
|
19
|
+
},
|
|
20
|
+
"./models": {
|
|
21
|
+
"types": "./dist/models.d.ts",
|
|
22
|
+
"import": "./dist/models.js"
|
|
23
|
+
},
|
|
24
|
+
"./settings": {
|
|
25
|
+
"types": "./dist/settings.d.ts",
|
|
26
|
+
"import": "./dist/settings.js"
|
|
27
|
+
},
|
|
28
|
+
"./commands": {
|
|
29
|
+
"types": "./dist/commands.d.ts",
|
|
30
|
+
"import": "./dist/commands.js"
|
|
31
|
+
},
|
|
32
|
+
"./presentation": {
|
|
33
|
+
"types": "./dist/presentation.d.ts",
|
|
34
|
+
"import": "./dist/presentation.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"docs/pi-common.md"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-ai": "*",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@earendil-works/pi-ai": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@earendil-works/pi-coding-agent": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@earendil-works/pi-tui": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@earendil-works/pi-ai": "1.0.4",
|
|
61
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
62
|
+
"@earendil-works/pi-tui": "1.0.4",
|
|
63
|
+
"typescript": "5.9.3",
|
|
64
|
+
"@types/node": "26.6.4"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc",
|
|
68
|
+
"typecheck": "tsc --noEmit",
|
|
69
|
+
"check": "node --test tests/*.test.mjs",
|
|
70
|
+
"check:consumers": "node --test integration/*.test.mjs",
|
|
71
|
+
"verify": "npm run build && npm run check",
|
|
72
|
+
"prepack": "npm run build",
|
|
73
|
+
"prepublishOnly": "npm run verify"
|
|
74
|
+
}
|
|
75
|
+
}
|