pi-soly 0.2.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.
package/nudge.ts ADDED
@@ -0,0 +1,123 @@
1
+ // =============================================================================
2
+ // nudge.ts — Behavioral nudge for the soly extension
3
+ // =============================================================================
4
+ //
5
+ // Goal: gently push the agent toward "ask before acting on non-trivial
6
+ // tasks" and "use background subagents with fresh context for research".
7
+ //
8
+ // Implementation is prompt-only (no UI blocking) — the model reads the nudge
9
+ // in its system prompt and follows it. Heuristics on the user prompt tell the
10
+ // model WHY this prompt triggers the nudge, so it can decide whether to ask
11
+ // one short clarifying question or just go.
12
+ //
13
+ // The input event also surfaces a soft UI notify to the human, so they know
14
+ // the model was told to pause and ask — but it never blocks input.
15
+ // =============================================================================
16
+
17
+ // Words that suggest the user is asking for non-trivial changes.
18
+ const NON_TRIVIAL_VERBS =
19
+ /\b(add|create|build|implement|refactor|migrate|rewrite|redesign|port|convert|integrate|introduce|extract|split|merge|restructure|optimize|generate|scaffold|set up|wire up)\b/i;
20
+
21
+ // Words that suggest the user is asking the model to go look something up.
22
+ const RESEARCH_VERBS =
23
+ /\b(find out|look up|check|verify|investigate|research|figure out|figure out how|discover|why does|how does|what is the best|compare|which library|which approach|benchmark|audit|review|trace|debug why)\b/i;
24
+
25
+ const URL_PATTERN = /https?:\/\/\S+/;
26
+ // Library/version-ish reference (e.g. v1.2.3, @scope/pkg).
27
+ // NB: no leading `\b` — `@` is a non-word char so `\b` won't match before it.
28
+ // We use a negative lookbehind on word chars instead.
29
+ const VERSION_PATTERN = /(?<!\w)(v?\d+\.\d+(?:\.\d+)?|@[\w\-]+\/[\w\-]+)(?!\w)/;
30
+
31
+ export interface TaskHeuristics {
32
+ nonTrivial: boolean;
33
+ researchHeavy: boolean;
34
+ mentions: string[];
35
+ suggestedAngles: string[];
36
+ }
37
+
38
+ export function classifyTaskHeuristics(prompt: string): TaskHeuristics {
39
+ const trimmed = prompt.trim();
40
+ const long = trimmed.length > 80;
41
+ const hasVerb = NON_TRIVIAL_VERBS.test(trimmed);
42
+ const hasResearch = RESEARCH_VERBS.test(trimmed);
43
+ const hasUrl = URL_PATTERN.test(trimmed);
44
+ const hasVersion = VERSION_PATTERN.test(trimmed);
45
+
46
+ // Extract file-ish mentions from the prompt. We don't import core's
47
+ // extractFilePathsFromPrompt to keep nudge.ts self-contained.
48
+ const fileLike =
49
+ trimmed.match(/(?:\.{0,2}\/)?(?:[A-Za-z0-9_\-]+\/)+[A-Za-z0-9_\-.]+\.[A-Za-z0-9]{1,5}/g) ||
50
+ trimmed.match(/[A-Za-z0-9_\-.]+\.[a-z]{1,5}/g) ||
51
+ [];
52
+ const mentions: string[] = [];
53
+ if (fileLike.length >= 2) mentions.push(`${fileLike.length} file references`);
54
+ if (hasUrl) mentions.push("external URL");
55
+ if (hasVersion) mentions.push("version/library ref");
56
+
57
+ // nonTrivial = enough complexity that assumptions matter
58
+ const nonTrivial =
59
+ long || hasVerb || fileLike.length >= 2 || mentions.length > 0;
60
+
61
+ // researchHeavy = model can't answer from rules/code alone
62
+ const researchHeavy = hasResearch || hasUrl || hasVersion;
63
+
64
+ // Suggested clarification angles — only what the heuristic actually
65
+ // detected, no over-eager prompting.
66
+ const suggestedAngles: string[] = [];
67
+ if (fileLike.length >= 2) {
68
+ suggestedAngles.push("which files are in scope vs out of scope?");
69
+ }
70
+ if (hasVerb && !hasUrl && !hasVersion) {
71
+ suggestedAngles.push("what does \"done\" look like for this task?");
72
+ }
73
+ if (researchHeavy) {
74
+ suggestedAngles.push(
75
+ "is there a specific source/result you trust, or should I dig?",
76
+ );
77
+ }
78
+ if (mentions.length === 0 && nonTrivial) {
79
+ suggestedAngles.push("any constraints (deadline, scope, style) I should know?");
80
+ }
81
+
82
+ return { nonTrivial, researchHeavy, mentions, suggestedAngles };
83
+ }
84
+
85
+ export function buildNudgeSection(heuristics: TaskHeuristics): string {
86
+ // Always-on rules (cheap to add, high signal):
87
+ // - Don't dive in on non-trivial tasks without a brief check
88
+ // - Prefer background subagents for research
89
+ // Conditional guidance based on what the prompt actually looks like.
90
+ const triggers: string[] = [];
91
+ if (heuristics.nonTrivial) {
92
+ triggers.push("non-trivial task (long prompt / action verb / multiple files)");
93
+ }
94
+ if (heuristics.researchHeavy) {
95
+ triggers.push("research-heavy (web lookup / library decision / unknown behavior)");
96
+ }
97
+
98
+ const triggerLine = triggers.length
99
+ ? `Heuristics for this prompt: ${triggers.join("; ")}.`
100
+ : "Heuristics for this prompt: looks routine.";
101
+
102
+ const anglesBlock = heuristics.suggestedAngles.length
103
+ ? `\n\nPossible clarifying questions (pick at most 1–2 that actually unblock you, or skip if you can answer from the prompt):\n${heuristics.suggestedAngles
104
+ .map((a, i) => ` ${i + 1}. ${a}`)
105
+ .join("\n")}`
106
+ : "";
107
+
108
+ return `
109
+
110
+ ## soly behavioral nudge (always on)
111
+
112
+ The following are user-set defaults, not project rules. They tell you how the user wants you to behave in this session.
113
+
114
+ 1. **Pre-action gate.** Before starting non-trivial work, take a 10-second pause and decide: do I have enough to act, or should I ask? If the prompt has ambiguity, missing scope, or a hidden assumption, surface one short clarifying question (or a small set of multi-choice options) instead of starting to code. Skip the gate for trivial fixes ("rename X", "add log line", "fix typo") and for follow-up turns in an already-clarified task.
115
+ ${triggerLine}${anglesBlock}
116
+
117
+ 2. **Background subagents by default.** When you need to read unfamiliar code, scout a directory, gather external evidence, or run a multi-step review, prefer \`subagent(...)\` with \`async: true\` and \`context: "fresh"\` over doing it inline. Reserve your own context for the actual decision the user is paying you to make. If the work needs the parent conversation history, use \`context: "fork"\` instead. Do not silently block on long runs — launch async and continue with independent work.
118
+
119
+ 3. **Subagent tool ergonomics.** When delegating: give the child a concrete role, scope, success criteria, hard constraints, and expected output. Do not pass vague instructions like "implement this" or "look into that". Async is the default; foreground is the explicit opt-out.
120
+
121
+ Treat (1) and (2) as defaults, not laws. The user can always override per-task ("just do it", "ask me everything", "no subagents"). When overriding, briefly acknowledge it.
122
+ `;
123
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "pi-soly",
3
+ "version": "0.2.1",
4
+ "description": "Project management for pi — plans, state, subagent-driven execution. Inspired by GSD.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "scripts": {
8
+ "test": "bun test",
9
+ "typecheck": "bun x tsc --noEmit"
10
+ },
11
+ "dependencies": {},
12
+ "peerDependencies": {
13
+ "@earendil-works/pi-coding-agent": "*",
14
+ "@earendil-works/pi-tui": "*"
15
+ },
16
+ "devDependencies": {
17
+ "@earendil-works/pi-ai": "0.78.1",
18
+ "@earendil-works/pi-coding-agent": "0.78.1",
19
+ "@types/node": "^25.9.1",
20
+ "bun-types": "^1.3.14",
21
+ "typebox": "1.1.38",
22
+ "typescript": "^6.0.3"
23
+ },
24
+ "files": [
25
+ "commands.ts",
26
+ "config.ts",
27
+ "core.ts",
28
+ "docs.ts",
29
+ "env.ts",
30
+ "git.ts",
31
+ "html.ts",
32
+ "index.ts",
33
+ "integrations.ts",
34
+ "intent.ts",
35
+ "iteration.ts",
36
+ "nudge.ts",
37
+ "scratchpad.ts",
38
+ "tools.ts",
39
+ "agents-install.ts",
40
+ "agents",
41
+ "workflows",
42
+ "workflows-data"
43
+ ],
44
+ "keywords": [
45
+ "pi",
46
+ "pi-extension",
47
+ "pi-package",
48
+ "project-management",
49
+ "planning",
50
+ "subagents"
51
+ ],
52
+ "license": "MIT",
53
+ "pi": {
54
+ "extensions": [
55
+ "./index.ts"
56
+ ]
57
+ },
58
+ "publishConfig": {
59
+ "registry": "https://registry.npmjs.org/"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "http://git.local.stbl/lowern1ght/pi-soly.framework.git",
64
+ "directory": "packages/soly"
65
+ }
66
+ }
package/scratchpad.ts ADDED
@@ -0,0 +1,117 @@
1
+ // =============================================================================
2
+ // scratchpad.ts — Working memory tool support
3
+ // =============================================================================
4
+ //
5
+ // Reads the recent conversation history (filtered to current branch) and
6
+ // produces a compact "scratchpad" summary: each turn's user prompt + first
7
+ // line of the assistant response. Used by soly_scratchpad tool to give the
8
+ // model (or a sibling context) cheap access to "what we just discussed".
9
+ //
10
+ // Pure: this module has no I/O and no state. It operates on the session
11
+ // branch that the LLM tool handler passes in.
12
+ // =============================================================================
13
+
14
+ const SCRATCHPAD_MAX_TURNS = 50;
15
+
16
+ export interface ScratchpadEntry {
17
+ turn: number;
18
+ role: "user" | "assistant" | "tool";
19
+ summary: string;
20
+ /** Tokens estimated for this entry. */
21
+ tokens: number;
22
+ }
23
+
24
+ export interface Scratchpad {
25
+ branchLength: number;
26
+ turnCount: number;
27
+ entries: ScratchpadEntry[];
28
+ fromTurn: number;
29
+ }
30
+
31
+ /** Extract a short, useful summary from a message's text content. */
32
+ function summarizeMessage(role: string, text: string, maxLen = 200): string {
33
+ if (!text) return "";
34
+ const trimmed = text.trim();
35
+ if (!trimmed) return "";
36
+ if (role === "tool") {
37
+ // Tool results: just first non-empty line
38
+ const first = trimmed.split(/\r?\n/).find((l) => l.trim());
39
+ return (first ?? "").slice(0, maxLen);
40
+ }
41
+ // For user/assistant: take the first paragraph or first maxLen chars
42
+ const firstPara = trimmed.split(/\r?\n\s*\r?\n/)[0] ?? trimmed;
43
+ return firstPara.length > maxLen
44
+ ? firstPara.slice(0, maxLen) + "…"
45
+ : firstPara;
46
+ }
47
+
48
+ /** Extract plain text from a message's content array. */
49
+ function messageText(content: unknown): string {
50
+ if (typeof content === "string") return content;
51
+ if (Array.isArray(content)) {
52
+ const parts: string[] = [];
53
+ for (const block of content) {
54
+ if (
55
+ block &&
56
+ typeof block === "object" &&
57
+ "type" in block &&
58
+ (block as { type: string }).type === "text" &&
59
+ "text" in block &&
60
+ typeof (block as { text: unknown }).text === "string"
61
+ ) {
62
+ parts.push((block as { text: string }).text);
63
+ }
64
+ }
65
+ return parts.join("\n");
66
+ }
67
+ return "";
68
+ }
69
+
70
+ /** Build a scratchpad of recent conversation turns. */
71
+ export function buildScratchpad(
72
+ entries: readonly { type: string; message?: { role?: string; content?: unknown } }[],
73
+ limit = 20,
74
+ ): Scratchpad {
75
+ // Walk backwards, collect messages, stop at limit turns (user prompts)
76
+ const collected: ScratchpadEntry[] = [];
77
+ let userTurnCount = 0;
78
+ const startIdx = entries.length;
79
+
80
+ for (let i = entries.length - 1; i >= 0; i--) {
81
+ const e = entries[i];
82
+ if (e.type !== "message" || !e.message) continue;
83
+ const role = e.message.role ?? "user";
84
+ const text = messageText(e.message.content);
85
+ const summary = summarizeMessage(role, text);
86
+ if (!summary) continue;
87
+ collected.unshift({
88
+ turn: startIdx - i,
89
+ role: role as ScratchpadEntry["role"],
90
+ summary,
91
+ tokens: Math.ceil(summary.length / 4),
92
+ });
93
+ if (role === "user") {
94
+ userTurnCount++;
95
+ if (userTurnCount >= limit) {
96
+ return {
97
+ branchLength: entries.length,
98
+ turnCount: userTurnCount,
99
+ entries: collected,
100
+ fromTurn: startIdx - i,
101
+ };
102
+ }
103
+ }
104
+ }
105
+
106
+ return {
107
+ branchLength: entries.length,
108
+ turnCount: userTurnCount,
109
+ entries: collected,
110
+ fromTurn: 1,
111
+ };
112
+ }
113
+
114
+ export const SCRATCHPAD_LIMITS = {
115
+ default: 20,
116
+ max: SCRATCHPAD_MAX_TURNS,
117
+ } as const;