@oh-my-pi/pi-coding-agent 3.4.1337 → 3.6.1337
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/CHANGELOG.md +22 -0
- package/package.json +5 -4
- package/src/core/sdk.ts +14 -1
- package/src/core/session-manager.ts +98 -69
- package/src/core/settings-manager.ts +33 -0
- package/src/core/system-prompt.ts +15 -0
- package/src/core/title-generator.ts +28 -6
- package/src/core/tools/index.ts +6 -0
- package/src/core/tools/rulebook.ts +124 -0
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +297 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +477 -0
- package/src/modes/interactive/components/extensions/index.ts +9 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
- package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
- package/src/modes/interactive/components/extensions/types.ts +191 -0
- package/src/modes/interactive/components/settings-defs.ts +2 -31
- package/src/modes/interactive/components/settings-selector.ts +0 -1
- package/src/modes/interactive/interactive-mode.ts +24 -296
- package/src/modes/print-mode.ts +34 -0
- package/src/modes/rpc/rpc-mode.ts +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [3.6.1337] - 2026-01-03
|
|
6
|
+
|
|
7
|
+
## [3.5.1337] - 2026-01-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added session header and footer output in text mode showing version, model, provider, thinking level, and session ID
|
|
12
|
+
- Added Extension Control Center dashboard accessible via `/extensions` command for unified management of all providers and extensions
|
|
13
|
+
- Added ability to enable/disable individual extensions with persistent settings
|
|
14
|
+
- Added three-column dashboard layout with sidebar tree, extension list, and inspector panel
|
|
15
|
+
- Added fuzzy search filtering for extensions in the dashboard
|
|
16
|
+
- Added keyboard navigation with Tab to cycle panes, j/k for navigation, Space to toggle, Enter to expand/collapse
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Redesigned Extension Control Center from 3-column layout to tabbed interface with horizontal provider tabs and 2-column grid
|
|
21
|
+
- Replaced sidebar tree navigation with provider tabs using TAB/Shift+TAB cycling
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed title generation flag not resetting when starting a new session
|
|
26
|
+
|
|
5
27
|
## [3.4.1337] - 2026-01-03
|
|
6
28
|
|
|
7
29
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1337",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-agent-core": "3.
|
|
43
|
-
"@oh-my-pi/pi-ai": "3.
|
|
44
|
-
"@oh-my-pi/pi-tui": "3.
|
|
42
|
+
"@oh-my-pi/pi-agent-core": "3.6.1337",
|
|
43
|
+
"@oh-my-pi/pi-ai": "3.6.1337",
|
|
44
|
+
"@oh-my-pi/pi-tui": "3.6.1337",
|
|
45
45
|
"@sinclair/typebox": "^0.34.46",
|
|
46
46
|
"ajv": "^8.17.1",
|
|
47
47
|
"chalk": "^5.5.0",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"highlight.js": "^11.11.1",
|
|
53
53
|
"marked": "^15.0.12",
|
|
54
54
|
"minimatch": "^10.1.1",
|
|
55
|
+
"nanoid": "^5.1.6",
|
|
55
56
|
"node-html-parser": "^6.1.13",
|
|
56
57
|
"smol-toml": "^1.6.0",
|
|
57
58
|
"strip-ansi": "^7.1.2",
|
package/src/core/sdk.ts
CHANGED
|
@@ -79,8 +79,10 @@ import {
|
|
|
79
79
|
createLsTool,
|
|
80
80
|
createReadOnlyTools,
|
|
81
81
|
createReadTool,
|
|
82
|
+
createRulebookTool,
|
|
82
83
|
createWriteTool,
|
|
83
84
|
editTool,
|
|
85
|
+
filterRulebookRules,
|
|
84
86
|
findTool,
|
|
85
87
|
grepTool,
|
|
86
88
|
lsTool,
|
|
@@ -604,7 +606,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
604
606
|
const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
|
|
605
607
|
time("discoverSkills");
|
|
606
608
|
|
|
607
|
-
// Discover
|
|
609
|
+
// Discover rules
|
|
608
610
|
const ttsrManager = createTtsrManager(settingsManager.getTtsrSettings());
|
|
609
611
|
const rulesResult = loadCapability<Rule>(ruleCapability.id, { cwd });
|
|
610
612
|
for (const rule of rulesResult.items) {
|
|
@@ -614,6 +616,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
614
616
|
}
|
|
615
617
|
time("discoverTtsrRules");
|
|
616
618
|
|
|
619
|
+
// Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
|
|
620
|
+
const rulebookRules = filterRulebookRules(rulesResult.items);
|
|
621
|
+
time("filterRulebookRules");
|
|
622
|
+
|
|
617
623
|
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
|
|
618
624
|
time("discoverContextFiles");
|
|
619
625
|
|
|
@@ -757,6 +763,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
757
763
|
|
|
758
764
|
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
|
|
759
765
|
|
|
766
|
+
// Add rulebook tool if there are rules with descriptions (always enabled, regardless of --tools)
|
|
767
|
+
if (rulebookRules.length > 0) {
|
|
768
|
+
allToolsArray.push(createRulebookTool(rulebookRules));
|
|
769
|
+
}
|
|
770
|
+
|
|
760
771
|
// Filter out hidden tools unless explicitly requested
|
|
761
772
|
if (options.explicitTools) {
|
|
762
773
|
const explicitSet = new Set(options.explicitTools);
|
|
@@ -781,6 +792,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
781
792
|
cwd,
|
|
782
793
|
skills,
|
|
783
794
|
contextFiles,
|
|
795
|
+
rulebookRules,
|
|
784
796
|
});
|
|
785
797
|
time("buildSystemPrompt");
|
|
786
798
|
|
|
@@ -791,6 +803,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
791
803
|
cwd,
|
|
792
804
|
skills,
|
|
793
805
|
contextFiles,
|
|
806
|
+
rulebookRules,
|
|
794
807
|
customPrompt: options.systemPrompt,
|
|
795
808
|
});
|
|
796
809
|
} else {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { join, resolve } from "node:path";
|
|
14
14
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
15
15
|
import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
|
|
16
|
+
import { nanoid } from "nanoid";
|
|
16
17
|
import { getAgentDir as getDefaultAgentDir } from "../config";
|
|
17
18
|
import {
|
|
18
19
|
type BashExecutionMessage,
|
|
@@ -196,11 +197,10 @@ export type ReadonlySessionManager = Pick<
|
|
|
196
197
|
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
|
197
198
|
function generateId(byId: { has(id: string): boolean }): string {
|
|
198
199
|
for (let i = 0; i < 100; i++) {
|
|
199
|
-
const id =
|
|
200
|
+
const id = nanoid(8);
|
|
200
201
|
if (!byId.has(id)) return id;
|
|
201
202
|
}
|
|
202
|
-
//
|
|
203
|
-
return crypto.randomUUID();
|
|
203
|
+
return nanoid(); // fallback to full nanoid
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
|
|
@@ -451,42 +451,105 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] {
|
|
|
451
451
|
return entries;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
454
|
+
/**
|
|
455
|
+
* Lightweight metadata for a session file, used in session picker UI.
|
|
456
|
+
* Uses lazy getters to defer string formatting until actually displayed.
|
|
457
|
+
*/
|
|
458
|
+
class RecentSessionInfo {
|
|
459
|
+
readonly path: string;
|
|
460
|
+
readonly mtime: number;
|
|
461
|
+
|
|
462
|
+
#fullName: string | undefined;
|
|
463
|
+
#name: string | undefined;
|
|
464
|
+
#timeAgo: string | undefined;
|
|
465
|
+
|
|
466
|
+
constructor(path: string, mtime: number, header: Record<string, unknown>) {
|
|
467
|
+
this.path = path;
|
|
468
|
+
this.mtime = mtime;
|
|
469
|
+
|
|
470
|
+
// Extract title from session header, falling back to id if title is missing
|
|
471
|
+
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
472
|
+
this.#fullName = trystr(header.title) ?? trystr(header.id);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Full session name from header, or filename without extension as fallback */
|
|
476
|
+
get fullName(): string {
|
|
477
|
+
if (this.#fullName) return this.#fullName;
|
|
478
|
+
this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
|
|
479
|
+
return this.#fullName;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Truncated name for display (max 40 chars) */
|
|
483
|
+
get name(): string {
|
|
484
|
+
if (this.#name) return this.#name;
|
|
485
|
+
const fullName = this.fullName;
|
|
486
|
+
this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 37)}...`;
|
|
487
|
+
return this.#name;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
491
|
+
get timeAgo(): string {
|
|
492
|
+
if (this.#timeAgo) return this.#timeAgo;
|
|
493
|
+
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
|
|
494
|
+
return this.#timeAgo;
|
|
466
495
|
}
|
|
467
496
|
}
|
|
468
497
|
|
|
469
|
-
/**
|
|
470
|
-
|
|
498
|
+
/**
|
|
499
|
+
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
500
|
+
* Uses low-level file I/O to efficiently read only the first 512 bytes of each file
|
|
501
|
+
* to extract the JSON header without loading entire session logs into memory.
|
|
502
|
+
*/
|
|
503
|
+
function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
|
|
471
504
|
try {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
505
|
+
// Reusable buffer for reading file headers
|
|
506
|
+
const buf = Buffer.allocUnsafe(512);
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Reads the first line (JSON header) from an open file descriptor.
|
|
510
|
+
* Returns null if the file is empty or doesn't start with valid JSON.
|
|
511
|
+
*/
|
|
512
|
+
const readHeader = (fd: number) => {
|
|
513
|
+
const bytesRead = readSync(fd, buf, 0, 512, 0);
|
|
514
|
+
if (bytesRead === 0) return null;
|
|
515
|
+
const sub = buf.subarray(0, bytesRead);
|
|
516
|
+
// Quick check: first char must be '{' for valid JSON object
|
|
517
|
+
if (sub.at(0) !== "{".charCodeAt(0)) return null;
|
|
518
|
+
// Find end of first JSON line
|
|
519
|
+
const eol = sub.indexOf("}\n");
|
|
520
|
+
if (eol <= 0) return null;
|
|
521
|
+
return JSON.parse(sub.toString("utf8", 0, eol + 1));
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
return readdirSync(sessionDir)
|
|
525
|
+
.map((f) => {
|
|
526
|
+
try {
|
|
527
|
+
if (!f.endsWith(".jsonl")) return null;
|
|
528
|
+
const path = join(sessionDir, f);
|
|
529
|
+
const fd = openSync(path, "r");
|
|
530
|
+
try {
|
|
531
|
+
const header = readHeader(fd);
|
|
532
|
+
if (!header) return null;
|
|
533
|
+
const mtime = statSync(path).mtimeMs;
|
|
534
|
+
return new RecentSessionInfo(path, mtime, header);
|
|
535
|
+
} finally {
|
|
536
|
+
closeSync(fd);
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
})
|
|
542
|
+
.filter((x) => x !== null)
|
|
543
|
+
.sort((a, b) => b.mtime - a.mtime); // Sort newest first
|
|
480
544
|
} catch {
|
|
481
|
-
return
|
|
545
|
+
return [];
|
|
482
546
|
}
|
|
483
547
|
}
|
|
484
548
|
|
|
485
|
-
/**
|
|
486
|
-
export
|
|
487
|
-
|
|
488
|
-
path
|
|
489
|
-
timeAgo: string;
|
|
549
|
+
/** Exported for testing */
|
|
550
|
+
export function findMostRecentSession(sessionDir: string): string | null {
|
|
551
|
+
const sessions = getSortedSessions(sessionDir);
|
|
552
|
+
return sessions[0]?.path || null;
|
|
490
553
|
}
|
|
491
554
|
|
|
492
555
|
/** Format a time difference as a human-readable string */
|
|
@@ -506,41 +569,7 @@ function formatTimeAgo(date: Date): string {
|
|
|
506
569
|
|
|
507
570
|
/** Get recent sessions for display in welcome screen */
|
|
508
571
|
export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
|
|
509
|
-
|
|
510
|
-
const files = readdirSync(sessionDir)
|
|
511
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
512
|
-
.map((f) => join(sessionDir, f))
|
|
513
|
-
.filter(isValidSessionFile)
|
|
514
|
-
.map((path) => {
|
|
515
|
-
const stat = statSync(path);
|
|
516
|
-
// Try to get session title or id from first line
|
|
517
|
-
let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
|
|
518
|
-
try {
|
|
519
|
-
const content = readFileSync(path, "utf-8");
|
|
520
|
-
const firstLine = content.split("\n")[0];
|
|
521
|
-
if (firstLine) {
|
|
522
|
-
const header = JSON.parse(firstLine) as SessionHeader;
|
|
523
|
-
if (header.type === "session") {
|
|
524
|
-
// Prefer title over id
|
|
525
|
-
name = header.title ?? header.id ?? name;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
} catch {
|
|
529
|
-
// Use filename as fallback
|
|
530
|
-
}
|
|
531
|
-
return { path, name, mtime: stat.mtime };
|
|
532
|
-
})
|
|
533
|
-
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
|
534
|
-
.slice(0, limit);
|
|
535
|
-
|
|
536
|
-
return files.map((f) => ({
|
|
537
|
-
name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
|
|
538
|
-
path: f.path,
|
|
539
|
-
timeAgo: formatTimeAgo(f.mtime),
|
|
540
|
-
}));
|
|
541
|
-
} catch {
|
|
542
|
-
return [];
|
|
543
|
-
}
|
|
572
|
+
return getSortedSessions(sessionDir).slice(0, limit);
|
|
544
573
|
}
|
|
545
574
|
|
|
546
575
|
/**
|
|
@@ -588,7 +617,7 @@ export class SessionManager {
|
|
|
588
617
|
if (existsSync(this.sessionFile)) {
|
|
589
618
|
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
590
619
|
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
591
|
-
this.sessionId = header?.id ??
|
|
620
|
+
this.sessionId = header?.id ?? nanoid();
|
|
592
621
|
this.sessionTitle = header?.title;
|
|
593
622
|
|
|
594
623
|
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
@@ -603,7 +632,7 @@ export class SessionManager {
|
|
|
603
632
|
}
|
|
604
633
|
|
|
605
634
|
newSession(options?: NewSessionOptions): string | undefined {
|
|
606
|
-
this.sessionId =
|
|
635
|
+
this.sessionId = nanoid();
|
|
607
636
|
const timestamp = new Date().toISOString();
|
|
608
637
|
const header: SessionHeader = {
|
|
609
638
|
type: "session",
|
|
@@ -1081,7 +1110,7 @@ export class SessionManager {
|
|
|
1081
1110
|
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
1082
1111
|
const pathWithoutLabels = path.filter((e) => e.type !== "label");
|
|
1083
1112
|
|
|
1084
|
-
const newSessionId =
|
|
1113
|
+
const newSessionId = nanoid();
|
|
1085
1114
|
const timestamp = new Date().toISOString();
|
|
1086
1115
|
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1087
1116
|
const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
@@ -105,6 +105,7 @@ export interface Settings {
|
|
|
105
105
|
edit?: EditSettings;
|
|
106
106
|
ttsr?: TtsrSettings;
|
|
107
107
|
disabledProviders?: string[]; // Discovery provider IDs that are disabled
|
|
108
|
+
disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit")
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
|
@@ -594,6 +595,38 @@ export class SettingsManager {
|
|
|
594
595
|
this.save();
|
|
595
596
|
}
|
|
596
597
|
|
|
598
|
+
getDisabledExtensions(): string[] {
|
|
599
|
+
return [...(this.settings.disabledExtensions ?? [])];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
setDisabledExtensions(extensionIds: string[]): void {
|
|
603
|
+
this.globalSettings.disabledExtensions = extensionIds;
|
|
604
|
+
this.save();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
isExtensionEnabled(extensionId: string): boolean {
|
|
608
|
+
return !(this.settings.disabledExtensions ?? []).includes(extensionId);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
enableExtension(extensionId: string): void {
|
|
612
|
+
const disabled = this.globalSettings.disabledExtensions ?? [];
|
|
613
|
+
const index = disabled.indexOf(extensionId);
|
|
614
|
+
if (index !== -1) {
|
|
615
|
+
disabled.splice(index, 1);
|
|
616
|
+
this.globalSettings.disabledExtensions = disabled;
|
|
617
|
+
this.save();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
disableExtension(extensionId: string): void {
|
|
622
|
+
const disabled = this.globalSettings.disabledExtensions ?? [];
|
|
623
|
+
if (!disabled.includes(extensionId)) {
|
|
624
|
+
disabled.push(extensionId);
|
|
625
|
+
this.globalSettings.disabledExtensions = disabled;
|
|
626
|
+
this.save();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
597
630
|
getTtsrSettings(): TtsrSettings {
|
|
598
631
|
return this.settings.ttsr ?? {};
|
|
599
632
|
}
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import chalk from "chalk";
|
|
7
7
|
import { contextFileCapability } from "../capability/context-file";
|
|
8
|
+
import type { Rule } from "../capability/rule";
|
|
8
9
|
import { systemPromptCapability } from "../capability/system-prompt";
|
|
9
10
|
import { getDocsPath, getExamplesPath, getReadmePath } from "../config";
|
|
10
11
|
import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
|
|
11
12
|
import type { SkillsSettings } from "./settings-manager";
|
|
12
13
|
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills";
|
|
13
14
|
import type { ToolName } from "./tools/index";
|
|
15
|
+
import { formatRulesForPrompt } from "./tools/rulebook";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Execute a git command synchronously and return stdout or null on failure.
|
|
@@ -238,6 +240,8 @@ export interface BuildSystemPromptOptions {
|
|
|
238
240
|
contextFiles?: Array<{ path: string; content: string; depth?: number }>;
|
|
239
241
|
/** Pre-loaded skills (skips discovery if provided). */
|
|
240
242
|
skills?: Skill[];
|
|
243
|
+
/** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
|
|
244
|
+
rulebookRules?: Rule[];
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
/** Build the system prompt with tools, guidelines, and context */
|
|
@@ -250,6 +254,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|
|
250
254
|
cwd,
|
|
251
255
|
contextFiles: providedContextFiles,
|
|
252
256
|
skills: providedSkills,
|
|
257
|
+
rulebookRules,
|
|
253
258
|
} = options;
|
|
254
259
|
const resolvedCwd = cwd ?? process.cwd();
|
|
255
260
|
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
|
|
@@ -310,6 +315,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|
|
310
315
|
prompt += formatSkillsForPrompt(skills);
|
|
311
316
|
}
|
|
312
317
|
|
|
318
|
+
// Append rules section (always enabled when rules exist)
|
|
319
|
+
if (rulebookRules && rulebookRules.length > 0) {
|
|
320
|
+
prompt += formatRulesForPrompt(rulebookRules);
|
|
321
|
+
}
|
|
322
|
+
|
|
313
323
|
// Add date/time and working directory last
|
|
314
324
|
prompt += `\nCurrent date and time: ${dateTime}`;
|
|
315
325
|
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
|
@@ -419,6 +429,11 @@ Documentation:
|
|
|
419
429
|
prompt += formatSkillsForPrompt(skills);
|
|
420
430
|
}
|
|
421
431
|
|
|
432
|
+
// Append rules section (always enabled when rules exist)
|
|
433
|
+
if (rulebookRules && rulebookRules.length > 0) {
|
|
434
|
+
prompt += formatRulesForPrompt(rulebookRules);
|
|
435
|
+
}
|
|
436
|
+
|
|
422
437
|
// Add date/time and working directory last
|
|
423
438
|
prompt += `\nCurrent date and time: ${dateTime}`;
|
|
424
439
|
prompt += `\nCurrent working directory: ${resolvedCwd}`;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Model } from "@oh-my-pi/pi-ai";
|
|
6
6
|
import { completeSimple } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import { logger } from "./logger";
|
|
7
8
|
import type { ModelRegistry } from "./model-registry";
|
|
8
9
|
import { findSmolModel } from "./model-resolver";
|
|
9
10
|
|
|
@@ -43,21 +44,35 @@ export async function generateSessionTitle(
|
|
|
43
44
|
savedSmolModel?: string,
|
|
44
45
|
): Promise<string | null> {
|
|
45
46
|
const model = await findTitleModel(registry, savedSmolModel);
|
|
46
|
-
if (!model)
|
|
47
|
+
if (!model) {
|
|
48
|
+
logger.debug("title-generator: no smol model found");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
47
51
|
|
|
48
52
|
const apiKey = await registry.getApiKey(model);
|
|
49
|
-
if (!apiKey)
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
logger.debug("title-generator: no API key for model", { provider: model.provider, id: model.id });
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
// Truncate message if too long
|
|
52
59
|
const truncatedMessage =
|
|
53
60
|
firstMessage.length > MAX_INPUT_CHARS ? `${firstMessage.slice(0, MAX_INPUT_CHARS)}...` : firstMessage;
|
|
54
61
|
|
|
62
|
+
const request = {
|
|
63
|
+
model: `${model.provider}/${model.id}`,
|
|
64
|
+
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
65
|
+
userMessage: `<user-message>\n${truncatedMessage}\n</user-message>`,
|
|
66
|
+
maxTokens: 30,
|
|
67
|
+
};
|
|
68
|
+
logger.debug("title-generator: request", request);
|
|
69
|
+
|
|
55
70
|
try {
|
|
56
71
|
const response = await completeSimple(
|
|
57
72
|
model,
|
|
58
73
|
{
|
|
59
|
-
systemPrompt:
|
|
60
|
-
messages: [{ role: "user", content:
|
|
74
|
+
systemPrompt: request.systemPrompt,
|
|
75
|
+
messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
|
|
61
76
|
},
|
|
62
77
|
{
|
|
63
78
|
apiKey,
|
|
@@ -74,13 +89,20 @@ export async function generateSessionTitle(
|
|
|
74
89
|
}
|
|
75
90
|
title = title.trim();
|
|
76
91
|
|
|
77
|
-
|
|
92
|
+
logger.debug("title-generator: response", {
|
|
93
|
+
title,
|
|
94
|
+
usage: response.usage,
|
|
95
|
+
stopReason: response.stopReason,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!title) {
|
|
78
99
|
return null;
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
// Clean up: remove quotes, trailing punctuation
|
|
82
103
|
return title.replace(/^["']|["']$/g, "").replace(/[.!?]$/, "");
|
|
83
|
-
} catch {
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.debug("title-generator: error", { error: err instanceof Error ? err.message : String(err) });
|
|
84
106
|
return null;
|
|
85
107
|
}
|
|
86
108
|
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -24,6 +24,12 @@ export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./no
|
|
|
24
24
|
export { createOutputTool, type OutputToolDetails, outputTool } from "./output";
|
|
25
25
|
export { createReadTool, type ReadToolDetails, readTool } from "./read";
|
|
26
26
|
export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review";
|
|
27
|
+
export {
|
|
28
|
+
createRulebookTool,
|
|
29
|
+
filterRulebookRules,
|
|
30
|
+
formatRulesForPrompt,
|
|
31
|
+
type RulebookToolDetails,
|
|
32
|
+
} from "./rulebook";
|
|
27
33
|
export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
|
|
28
34
|
export type { TruncationResult } from "./truncate";
|
|
29
35
|
export { createWebFetchTool, type WebFetchToolDetails, webFetchCustomTool, webFetchTool } from "./web-fetch";
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rulebook Tool
|
|
3
|
+
*
|
|
4
|
+
* Allows the agent to fetch full content of rules that have descriptions.
|
|
5
|
+
* Rules are listed in the system prompt with name + description; this tool
|
|
6
|
+
* retrieves the complete rule content on demand.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
10
|
+
import { Type } from "@sinclair/typebox";
|
|
11
|
+
import type { Rule } from "../../capability/rule";
|
|
12
|
+
|
|
13
|
+
export interface RulebookToolDetails {
|
|
14
|
+
type: "rulebook";
|
|
15
|
+
ruleName: string;
|
|
16
|
+
found: boolean;
|
|
17
|
+
content?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rulebookSchema = Type.Object({
|
|
21
|
+
name: Type.String({ description: "The name of the rule to fetch" }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a rulebook tool with access to discovered rules.
|
|
26
|
+
* @param rules - Array of discovered rules (non-TTSR rules with descriptions)
|
|
27
|
+
*/
|
|
28
|
+
export function createRulebookTool(rules: Rule[]): AgentTool<typeof rulebookSchema> {
|
|
29
|
+
// Build lookup map for O(1) access
|
|
30
|
+
const ruleMap = new Map<string, Rule>();
|
|
31
|
+
for (const rule of rules) {
|
|
32
|
+
ruleMap.set(rule.name, rule);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ruleNames = rules.map((r) => r.name);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: "rulebook",
|
|
39
|
+
label: "Rulebook",
|
|
40
|
+
description: `Fetch the full content of a project rule by name. Use this when a rule listed in <available_rules> is relevant to your current task. Available: ${ruleNames.join(", ") || "(none)"}`,
|
|
41
|
+
parameters: rulebookSchema,
|
|
42
|
+
execute: async (_toolCallId: string, { name }: { name: string }) => {
|
|
43
|
+
const rule = ruleMap.get(name);
|
|
44
|
+
|
|
45
|
+
if (!rule) {
|
|
46
|
+
const available = ruleNames.join(", ");
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: `Rule "${name}" not found. Available rules: ${available || "(none)"}` }],
|
|
49
|
+
details: {
|
|
50
|
+
type: "rulebook",
|
|
51
|
+
ruleName: name,
|
|
52
|
+
found: false,
|
|
53
|
+
} satisfies RulebookToolDetails,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: `# Rule: ${rule.name}\n\n${rule.content}` }],
|
|
59
|
+
details: {
|
|
60
|
+
type: "rulebook",
|
|
61
|
+
ruleName: name,
|
|
62
|
+
found: true,
|
|
63
|
+
content: rule.content,
|
|
64
|
+
} satisfies RulebookToolDetails,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Filter rules to only those suitable for the rulebook (have descriptions, no TTSR trigger).
|
|
72
|
+
*/
|
|
73
|
+
export function filterRulebookRules(rules: Rule[]): Rule[] {
|
|
74
|
+
return rules.filter((rule) => {
|
|
75
|
+
// Exclude TTSR rules (handled separately by streaming)
|
|
76
|
+
if (rule.ttsrTrigger) return false;
|
|
77
|
+
// Exclude always-apply rules (already in context)
|
|
78
|
+
if (rule.alwaysApply) return false;
|
|
79
|
+
// Must have a description for agent to know when to fetch
|
|
80
|
+
if (!rule.description) return false;
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format rules for inclusion in the system prompt.
|
|
87
|
+
* Lists rule names and descriptions so the agent knows what's available.
|
|
88
|
+
*/
|
|
89
|
+
export function formatRulesForPrompt(rules: Rule[]): string {
|
|
90
|
+
if (rules.length === 0) {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lines = [
|
|
95
|
+
"\n\n## Available Rules",
|
|
96
|
+
"",
|
|
97
|
+
"The following project rules are available. Use the `rulebook` tool to fetch a rule's full content when it's relevant to your task.",
|
|
98
|
+
"",
|
|
99
|
+
"<available_rules>",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
for (const rule of rules) {
|
|
103
|
+
lines.push(" <rule>");
|
|
104
|
+
lines.push(` <name>${escapeXml(rule.name)}</name>`);
|
|
105
|
+
lines.push(` <description>${escapeXml(rule.description || "")}</description>`);
|
|
106
|
+
if (rule.globs && rule.globs.length > 0) {
|
|
107
|
+
lines.push(` <globs>${escapeXml(rule.globs.join(", "))}</globs>`);
|
|
108
|
+
}
|
|
109
|
+
lines.push(" </rule>");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push("</available_rules>");
|
|
113
|
+
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function escapeXml(str: string): string {
|
|
118
|
+
return str
|
|
119
|
+
.replace(/&/g, "&")
|
|
120
|
+
.replace(/</g, "<")
|
|
121
|
+
.replace(/>/g, ">")
|
|
122
|
+
.replace(/"/g, """)
|
|
123
|
+
.replace(/'/g, "'");
|
|
124
|
+
}
|