pi-mood 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.
Files changed (4) hide show
  1. package/README.md +58 -0
  2. package/mood.ts +228 -0
  3. package/package.json +12 -0
  4. package/test.ts +159 -0
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # pi-mood
2
+
3
+ Periodic rule reminders injected as system messages into LLM context. Rules are
4
+ extracted from your AGENTS.md — no duplication.
5
+
6
+ ## How it works
7
+
8
+ Reads `~/.pi/agent/AGENTS.md` and `<cwd>/AGENTS.md`, parses headings annotated
9
+ with `@N`, and injects the rule text as a persistent system message between LLM
10
+ calls. Rules are selected with weighted probability and a cooldown to avoid
11
+ repetition.
12
+
13
+ Headings without `@N` are ignored. Any heading level works (`##`, `###`, etc.).
14
+ Parent sections include their subsection headings and bodies.
15
+
16
+ ```markdown
17
+ ## Design review @5
18
+
19
+ Before any change, write a design brief and send to reviewer. If trivial, show
20
+ brief to user and request skip.
21
+
22
+ ### What should be in the brief @3
23
+
24
+ ...
25
+ ```
26
+
27
+ - `@5` — weight. Higher = selected more often.
28
+ - After selection, the rule's effective weight is halved for the next pick
29
+ (2^(-times_shown)).
30
+ - Rules are injected every `MOOD_EVERY` LLM calls (default 5).
31
+
32
+ ## Status bar
33
+
34
+ ```
35
+ mood: — · — · — (fresh start)
36
+ mood: 1.2kt · now · «Design review» (just injected)
37
+ mood: 3.5kt · 4 ago · «Say it straight» (between injections)
38
+ ```
39
+
40
+ Three pieces, always two separators. Tokens, time since last injection, current
41
+ rule. Restored from session on resume.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pi install ~/git/pi-mood
47
+ ```
48
+
49
+ ## Config
50
+
51
+ - `MOOD_EVERY` — environment variable, default 5. Inject rule every N LLM
52
+ calls.
53
+
54
+ ## Tests
55
+
56
+ ```bash
57
+ npx tsx test.ts
58
+ ```
package/mood.ts ADDED
@@ -0,0 +1,228 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { estimateTokens } from "@earendil-works/pi-coding-agent";
3
+ import { readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+
8
+ interface Rule {
9
+ heading: string;
10
+ weight: number;
11
+ body: string;
12
+ }
13
+
14
+ function parse(content: string): Rule[] {
15
+ const lines = content.split("\n");
16
+ const rules: Rule[] = [];
17
+ const stack: { heading: string; weight: number; body: string[]; level: number }[] = [];
18
+
19
+ function popUntil(level: number) {
20
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
21
+ const s = stack.pop()!;
22
+ const body = s.body.join("\n").trim();
23
+ if (body.length > 0 && s.weight > 0) {
24
+ rules.push({ heading: s.heading, weight: s.weight, body });
25
+ }
26
+ }
27
+ }
28
+
29
+ for (const line of lines) {
30
+ const m = line.match(/^(#{1,6})\s+(.+)/);
31
+ if (m) {
32
+ const level = m[1].length;
33
+ let heading = m[2].trim();
34
+ let weight = 0;
35
+ const w = heading.match(/\s*@(\d+)\s*$/);
36
+ if (w) {
37
+ weight = Math.max(1, parseInt(w[1], 10));
38
+ heading = heading.slice(0, heading.length - w[0].length).trim();
39
+ }
40
+
41
+ popUntil(level);
42
+
43
+ const section = { heading, weight, body: [] as string[], level };
44
+ const cleaned = "#".repeat(level) + " " + heading;
45
+ for (const s of stack) s.body.push(cleaned);
46
+ stack.push(section);
47
+ } else {
48
+ for (const s of stack) s.body.push(line);
49
+ }
50
+ }
51
+ popUntil(0);
52
+
53
+ return rules;
54
+ }
55
+
56
+ function weightedPick(rules: Rule[], shown: Map<Rule, number>): Rule {
57
+ const w = (r: Rule) => r.weight * Math.pow(2, -(shown.get(r) ?? 0));
58
+ const total = rules.reduce((s, r) => s + w(r), 0);
59
+ let r = Math.random() * total;
60
+ for (const rule of rules) {
61
+ r -= w(rule);
62
+ if (r <= 0) return rule;
63
+ }
64
+ return rules[rules.length - 1];
65
+ }
66
+
67
+ const DEFAULT_EVERY = 5;
68
+
69
+ function resolveEvery(): number {
70
+ const v = process.env.MOOD_EVERY;
71
+ if (v) {
72
+ const n = parseInt(v, 10);
73
+ if (n > 0) return n;
74
+ }
75
+ return DEFAULT_EVERY;
76
+ }
77
+
78
+ function trunc(s: string, max: number): string {
79
+ return s.length <= max ? s : s.slice(0, max - 1) + "…";
80
+ }
81
+
82
+ function fmtTokens(n: number): string {
83
+ if (n === 0) return "—";
84
+ if (n < 1000) return `${n}t`;
85
+ if (n < 10000) return `${(n / 1000).toFixed(1)}kt`;
86
+ return `${Math.round(n / 1000)}kt`;
87
+ }
88
+
89
+ function fmtTime(ago: number, active: boolean): string {
90
+ if (!active) return "—";
91
+ if (ago === 0) return "now";
92
+ return `${ago} ago`;
93
+ }
94
+
95
+ function fmtRule(rule: Rule | null): string {
96
+ if (!rule) return "—";
97
+ return `«${trunc(rule.heading, 30)}»`;
98
+ }
99
+
100
+ function statusLine(
101
+ tokens: number,
102
+ ago: number,
103
+ active: boolean,
104
+ rule: Rule | null,
105
+ ): string {
106
+ return `mood: ${fmtTokens(tokens)} · ${fmtTime(ago, active)} · ${fmtRule(rule)}`;
107
+ }
108
+
109
+ export default function (pi: ExtensionAPI) {
110
+ let rules: Rule[] = [];
111
+ let every = DEFAULT_EVERY;
112
+ let calls = 0;
113
+ let totalTokens = 0;
114
+ let currentRule: Rule | null = null;
115
+ let shown = new Map<Rule, number>();
116
+ let lastInjection = 0;
117
+
118
+ async function refresh(cwd: string) {
119
+ const paths = [
120
+ join(homedir(), ".pi/agent/AGENTS.md"),
121
+ join(cwd, "AGENTS.md"),
122
+ ];
123
+
124
+ rules = [];
125
+ for (const p of paths) {
126
+ if (!existsSync(p)) continue;
127
+ try {
128
+ const content = await readFile(p, "utf-8");
129
+ rules.push(...parse(content));
130
+ } catch {
131
+ // skip
132
+ }
133
+ }
134
+
135
+ every = resolveEvery();
136
+ calls = 0;
137
+ totalTokens = 0;
138
+ currentRule = null;
139
+ shown = new Map();
140
+ lastInjection = 0;
141
+ }
142
+
143
+ function show(ctx: any) {
144
+ const ago = calls - lastInjection;
145
+ const active = currentRule !== null;
146
+ ctx.ui.setStatus(
147
+ "mood",
148
+ statusLine(totalTokens, ago, active, currentRule),
149
+ );
150
+ }
151
+
152
+ pi.on("session_start", async (event, ctx) => {
153
+ await refresh(ctx.cwd);
154
+
155
+ const entries = ctx.sessionManager.getEntries() as any[];
156
+
157
+ let assistantCount = 0;
158
+ let lastMoodAt = 0;
159
+ let foundMood = false;
160
+
161
+ for (const e of entries) {
162
+ if (e.customType === "mood") {
163
+ foundMood = true;
164
+ lastMoodAt = assistantCount;
165
+ const text = e.content as string;
166
+ const matching = rules.find((r) => text.startsWith(r.heading));
167
+ if (matching) currentRule = matching;
168
+ }
169
+ if (e.role === "assistant") assistantCount++;
170
+ }
171
+
172
+ if (foundMood) {
173
+ calls = assistantCount;
174
+ lastInjection = lastMoodAt;
175
+ totalTokens = entries
176
+ .filter((e: any) => e.customType === "mood")
177
+ .reduce(
178
+ (s: number, e: any) =>
179
+ s +
180
+ estimateTokens({
181
+ role: "custom",
182
+ content: [{ type: "text", text: e.content }],
183
+ }),
184
+ 0,
185
+ );
186
+ }
187
+
188
+ show(ctx);
189
+ });
190
+
191
+ pi.on("context", async (event, ctx) => {
192
+ if (rules.length === 0) return;
193
+ if (!currentRule) {
194
+ currentRule = weightedPick(rules, shown);
195
+ shown.set(currentRule, (shown.get(currentRule) ?? 0) + 1);
196
+ }
197
+ calls++;
198
+
199
+ if (calls % every === 0 || calls === 1) {
200
+ currentRule = weightedPick(rules, shown);
201
+ shown.set(currentRule, (shown.get(currentRule) ?? 0) + 1);
202
+ lastInjection = calls;
203
+
204
+ const text = `${currentRule.heading}\n\n${currentRule.body}`;
205
+
206
+ totalTokens += estimateTokens({
207
+ role: "custom",
208
+ content: [{ type: "text", text }],
209
+ });
210
+
211
+ pi.sendMessage(
212
+ { customType: "mood", content: text, display: false },
213
+ { deliverAs: "steer" },
214
+ );
215
+
216
+ event.messages.push({
217
+ role: "system",
218
+ content: [{ type: "text", text }],
219
+ });
220
+ }
221
+
222
+ show(ctx);
223
+
224
+ if (calls % every === 0 || calls === 1) {
225
+ return { messages: event.messages };
226
+ }
227
+ });
228
+ }
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "pi-mood",
3
+ "version": "0.1.0",
4
+ "description": "Periodic rule reminders from AGENTS.md — weighted probabilistic injection via context event",
5
+ "keywords": ["pi-package"],
6
+ "peerDependencies": {
7
+ "@earendil-works/pi-coding-agent": "*"
8
+ },
9
+ "pi": {
10
+ "extensions": ["./mood.ts"]
11
+ }
12
+ }
package/test.ts ADDED
@@ -0,0 +1,159 @@
1
+ // Test helpers
2
+ let passed = 0;
3
+ let failed = 0;
4
+
5
+ function assert(cond: boolean, msg: string) {
6
+ if (cond) { passed++; }
7
+ else { failed++; console.error(`FAIL: ${msg}`); }
8
+ }
9
+
10
+ function eq<T>(a: T, b: T, msg: string) {
11
+ if (JSON.stringify(a) === JSON.stringify(b)) { passed++; }
12
+ else {
13
+ failed++;
14
+ console.error(`FAIL: ${msg}`);
15
+ console.error(` expected: ${JSON.stringify(b)}`);
16
+ console.error(` got: ${JSON.stringify(a)}`);
17
+ }
18
+ }
19
+
20
+ // Parser under test
21
+ function parse(content: string): { heading: string; weight: number; body: string }[] {
22
+ const lines = content.split("\n");
23
+ const rules: { heading: string; weight: number; body: string }[] = [];
24
+ const stack: { heading: string; weight: number; body: string[]; level: number }[] = [];
25
+
26
+ function popUntil(level: number) {
27
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
28
+ const s = stack.pop()!;
29
+ const body = s.body.join("\n").trim();
30
+ if (body.length > 0 && s.weight > 0) {
31
+ rules.push({ heading: s.heading, weight: s.weight, body });
32
+ }
33
+ }
34
+ }
35
+
36
+ for (const line of lines) {
37
+ const m = line.match(/^(#{1,6})\s+(.+)/);
38
+ if (m) {
39
+ const level = m[1].length;
40
+ let heading = m[2].trim();
41
+ let weight = 0;
42
+ const w = heading.match(/\s*@(\d+)\s*$/);
43
+ if (w) {
44
+ weight = Math.max(1, parseInt(w[1], 10));
45
+ heading = heading.slice(0, heading.length - w[0].length).trim();
46
+ }
47
+
48
+ popUntil(level);
49
+
50
+ const section = { heading, weight, body: [] as string[], level };
51
+ const cleanHeading = "#".repeat(level) + " " + heading;
52
+ for (const s of stack) s.body.push(cleanHeading);
53
+ stack.push(section);
54
+ } else {
55
+ for (const s of stack) s.body.push(line);
56
+ }
57
+ }
58
+ popUntil(0);
59
+
60
+ return rules;
61
+ }
62
+
63
+ // ===== Tests =====
64
+
65
+ // Flat - only weighted rules appear
66
+ {
67
+ const r = parse("## A @3\nbody A\n## B\nbody B\n## C @5\nbody C");
68
+ eq(r.length, 2, "only weighted rules");
69
+ eq(r[0].heading, "A", "A heading");
70
+ eq(r[0].weight, 3, "A weight");
71
+ eq(r[0].body, "body A", "A body");
72
+ eq(r[1].heading, "C", "C heading");
73
+ eq(r[1].weight, 5, "C weight");
74
+ eq(r[1].body, "body C", "C body");
75
+ }
76
+
77
+ // Nested - parent includes child heading + body
78
+ {
79
+ const r = parse("## Process @3\nproc body\n### Sub @2\nsub body\n## Code @5\ncode");
80
+ eq(r.length, 3, "three rules");
81
+ eq(r[0].heading, "Sub", "Sub first (popped before Process)");
82
+ eq(r[0].body, "sub body", "Sub body");
83
+ eq(r[1].heading, "Process", "Process second");
84
+ eq(r[1].body, "proc body\n### Sub\nsub body", "Process includes sub heading + body");
85
+ eq(r[2].heading, "Code", "Code last");
86
+ }
87
+
88
+ // @N stripped from child heading in parent body
89
+ {
90
+ const r = parse("## Parent @3\np body\n### Child @4\nc body");
91
+ eq(r.length, 2, "two rules");
92
+ assert(r[1].body.includes("### Child"), "parent body has child heading");
93
+ assert(!r[1].body.includes("@4"), "parent body does NOT have @4");
94
+ }
95
+
96
+ // Weight required - no @N = not a rule
97
+ {
98
+ const r = parse("## A\nno weight\n## B @2\nwith weight");
99
+ eq(r.length, 1, "only B");
100
+ eq(r[0].heading, "B", "B");
101
+ }
102
+
103
+ // Empty body skipped
104
+ {
105
+ const r = parse("## A @2\n## B @3\nbody B");
106
+ eq(r.length, 1, "A skipped (empty body)");
107
+ eq(r[0].heading, "B", "B");
108
+ }
109
+
110
+ // Top-level # Title without @N is not a rule, but doesn't break
111
+ {
112
+ const r = parse("# Title\n## Rule @3\nbody");
113
+ eq(r.length, 1, "one rule, Title ignored");
114
+ eq(r[0].heading, "Rule", "Rule");
115
+ }
116
+
117
+ // Whitespace in body handled
118
+ {
119
+ const r = parse("## A @2\n\nbody\n\n\n");
120
+ eq(r.length, 1, "one rule");
121
+ eq(r[0].body, "body", "body trimmed");
122
+ }
123
+
124
+ // @N at end of heading with various spacing
125
+ {
126
+ const r = parse("## A@3\nbody\n## B @5\nbody\n## C @2\nbody");
127
+ eq(r[0].heading, "A", "A without @3");
128
+ eq(r[0].weight, 3, "A weight 3");
129
+ eq(r[1].heading, "B", "B");
130
+ eq(r[1].weight, 5, "B weight 5");
131
+ eq(r[2].heading, "C", "C with extra space");
132
+ eq(r[2].weight, 2, "C weight 2");
133
+ }
134
+
135
+ // Deep nesting
136
+ {
137
+ const r = parse("## A @5\na\n### B @3\nb\n#### C @2\nc\n## D @4\nd");
138
+ eq(r.length, 4, "4 rules");
139
+ eq(r[0].heading, "C", "deepest first");
140
+ eq(r[1].heading, "B", "mid");
141
+ eq(r[2].heading, "A", "shallow");
142
+ assert(r[2].body.includes("### B") && r[2].body.includes("#### C"), "A includes B and C");
143
+ }
144
+
145
+ // EOF - unterminated last section
146
+ {
147
+ const r = parse("## A @2\nbody");
148
+ eq(r.length, 1, "last section flushed by popUntil(0)");
149
+ }
150
+
151
+ // No rules at all
152
+ {
153
+ const r = parse("just text\nno headings");
154
+ eq(r.length, 0, "no rules");
155
+ }
156
+
157
+ // ===== Report =====
158
+ console.log(`\n${passed} passed, ${failed} failed`);
159
+ process.exit(failed > 0 ? 1 : 0);