Skip to content

Commit ac420f2

Browse files
committed
Add active notebook topic system with set/clear and TUI indicator
1 parent 924b666 commit ac420f2

6 files changed

Lines changed: 177 additions & 8 deletions

File tree

handoff/compact.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
99
import type { AgenticodingState } from "../state.js";
10+
import { clearActiveNotebookTopic } from "../notebook/topic.js";
1011
import { STATUS_KEY_HANDOFF } from "../tui.js";
1112

1213
function getImpossibleKeptId(branchEntries: SessionEntry[]): string {
@@ -23,6 +24,7 @@ export function registerHandoffCompaction(pi: ExtensionAPI, state: AgenticodingS
2324

2425
state.pendingHandoff = null;
2526
state.pendingRequestedHandoff = null;
27+
clearActiveNotebookTopic(state);
2628

2729
// Clear the handoff progress indicator now that compaction is consuming it
2830
if (ctx.hasUI) {

index.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import { CONTEXT_PRIMER } from "./system-prompt.js";
2525
import { buildNudge, registerWatchdog } from "./watchdog.js";
2626
import { registerNotebookTools } from "./notebook/tools.js";
2727
import { registerNotebookRehydration } from "./notebook/rehydration.js";
28+
import { registerNotebookTopicTool } from "./notebook/topic-tool.js";
29+
import { setActiveNotebookTopic } from "./notebook/topic.js";
2830
import { registerHandoffTool } from "./handoff/tool.js";
2931
import { registerHandoffCommand } from "./handoff/command.js";
3032
import { registerHandoffCompaction } from "./handoff/compact.js";
3133
import { registerSpawnTool } from "./spawn/index.js";
3234
import {
3335
STATUS_KEY_HANDOFF,
36+
STATUS_KEY_TOPIC,
3437
WIDGET_KEY_WARNING,
3538
updateIndicators,
3639
} from "./tui.js";
@@ -41,6 +44,7 @@ export default function (pi: ExtensionAPI): void {
4144

4245
// ── Register all tools ──────────────────────────────────────────
4346
registerNotebookTools(pi, state);
47+
registerNotebookTopicTool(pi, state);
4448
registerHandoffTool(pi, state);
4549
registerSpawnTool(pi, state);
4650

@@ -54,8 +58,20 @@ export default function (pi: ExtensionAPI): void {
5458

5559
// ── /notebook command — interactive page selector ────────────────
5660
pi.registerCommand("notebook", {
57-
description: "Select a notebook page to preview",
58-
handler: async (_args, ctx) => {
61+
description: "Select a notebook page to preview, or set the active notebook topic with /notebook <topic>",
62+
handler: async (args, ctx) => {
63+
const topicArg = args.trim();
64+
if (topicArg) {
65+
const result = setActiveNotebookTopic(state, topicArg, "human");
66+
if (ctx.hasUI) {
67+
const message = result.boundaryHint
68+
? `Active notebook topic changed: ${result.boundaryHint.from}${result.boundaryHint.to}. This is a likely task boundary; handoff is recommended before continuing.`
69+
: `Active notebook topic: ${result.current}`;
70+
ctx.ui.notify(message, result.boundaryHint ? "warning" : "info");
71+
}
72+
updateIndicators(ctx, state);
73+
return;
74+
}
5975
if (!ctx.hasUI) {
6076
return;
6177
}
@@ -152,12 +168,25 @@ export default function (pi: ExtensionAPI): void {
152168
// Inject context management primer at the end of the system prompt
153169
parts.push("\n" + CONTEXT_PRIMER);
154170

155-
// Inject ledger listing so the LLM always knows what's available
156-
const entryNames = Array.from(state.ledger.keys()).sort();
171+
if (state.activeNotebookTopic) {
172+
parts.push(
173+
`\n## Active Notebook Topic\n` +
174+
`Current topic: \`${state.activeNotebookTopic}\` (${state.activeNotebookTopicSource ?? "unknown"}-set).\n` +
175+
`Treat this as the current semantic frame. If new work fits it, prefer spawn for isolated noisy subtasks. If it does not fit it, prefer handoff over dragging stale context forward.`,
176+
);
177+
} else {
178+
parts.push(
179+
`\n## Active Notebook Topic\n` +
180+
`No active notebook topic is set. Early in the next substantive task, assign a short stable topic with \`notebook_topic_set\`. Human-set topics are authoritative.`,
181+
);
182+
}
183+
184+
// Inject notebook listing so the LLM always knows what's available
185+
const entryNames = Array.from(state.notebookPages.keys()).sort();
157186
if (entryNames.length > 0) {
158187
const listing = entryNames
159188
.map((name) => {
160-
const content = state.ledger.get(name)!;
189+
const content = state.notebookPages.get(name)!;
161190
const firstLine = (content.split("\n")[0] ?? "").slice(0, 80);
162191
return ` ${name}: ${firstLine}`;
163192
})
@@ -201,6 +230,7 @@ export default function (pi: ExtensionAPI): void {
201230
// Clear any stale TUI indicators from the previous session
202231
if (ctx.hasUI) {
203232
ctx.ui.setStatus(STATUS_KEY_HANDOFF, undefined);
233+
ctx.ui.setStatus(STATUS_KEY_TOPIC, undefined);
204234
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);
205235
}
206236
}

notebook/topic-tool.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2+
import { Type } from "typebox";
3+
import type { AgenticodingState } from "../state.js";
4+
import { normalizeNotebookTopic, setActiveNotebookTopic } from "./topic.js";
5+
6+
export function registerNotebookTopicTool(
7+
pi: ExtensionAPI,
8+
state: AgenticodingState,
9+
): void {
10+
pi.registerTool({
11+
name: "notebook_topic_set",
12+
label: "Notebook Topic Set",
13+
description:
14+
"Set the active notebook topic for the current session. " +
15+
"Use this to establish the current semantic frame when no topic is set yet. " +
16+
"Human-set topics are authoritative and cannot be overridden by the agent.",
17+
promptSnippet: "Set the active notebook topic for the current session",
18+
promptGuidelines: [
19+
"Use this early in a fresh session when no active notebook topic exists yet.",
20+
"Do not use this to override a human-set topic. If the work no longer fits the current topic, prefer handoff instead.",
21+
],
22+
parameters: Type.Object({
23+
topic: Type.String({
24+
description: "Short stable notebook topic name for the current semantic frame.",
25+
}),
26+
}),
27+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
28+
const normalized = normalizeNotebookTopic(params.topic);
29+
if (state.activeNotebookTopic) {
30+
if (state.activeNotebookTopic !== normalized) {
31+
throw new Error(
32+
state.activeNotebookTopicSource === "human"
33+
? "Human-set notebook topic is authoritative. Use handoff instead of overriding it."
34+
: "Active notebook topic already exists. Use handoff instead of changing it mid-session.",
35+
);
36+
}
37+
return {
38+
content: [{ type: "text", text: `Notebook topic already set to \"${state.activeNotebookTopic}\".` }],
39+
details: {
40+
topic: state.activeNotebookTopic,
41+
source: state.activeNotebookTopicSource,
42+
changed: false,
43+
},
44+
};
45+
}
46+
const result = setActiveNotebookTopic(state, params.topic, "agent");
47+
return {
48+
content: [{ type: "text", text: `Active notebook topic: \"${result.current}\".` }],
49+
details: { topic: result.current, source: "agent" as const, changed: result.changed },
50+
};
51+
},
52+
});
53+
}

notebook/topic.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { AgenticodingState } from "../state.js";
2+
3+
export type NotebookTopicSource = "human" | "agent";
4+
5+
export interface NotebookTopicBoundaryHint {
6+
from: string | null;
7+
to: string;
8+
source: NotebookTopicSource;
9+
}
10+
11+
export function normalizeNotebookTopic(input: string): string {
12+
return input
13+
.trim()
14+
.toLowerCase()
15+
.replace(/[^a-z0-9]+/g, "-")
16+
.replace(/^-+|-+$/g, "")
17+
.slice(0, 80);
18+
}
19+
20+
export function setActiveNotebookTopic(
21+
state: AgenticodingState,
22+
topic: string,
23+
source: NotebookTopicSource,
24+
): { changed: boolean; previous: string | null; current: string; boundaryHint: NotebookTopicBoundaryHint | null } {
25+
const normalized = normalizeNotebookTopic(topic);
26+
if (!normalized) {
27+
throw new Error("Notebook topic cannot be empty.");
28+
}
29+
30+
const previous = state.activeNotebookTopic;
31+
const changed = previous !== normalized;
32+
state.activeNotebookTopic = normalized;
33+
state.activeNotebookTopicSource = source;
34+
35+
const boundaryHint = changed && previous !== null
36+
? { from: previous, to: normalized, source }
37+
: null;
38+
state.pendingTopicBoundaryHint = boundaryHint;
39+
40+
return {
41+
changed,
42+
previous,
43+
current: normalized,
44+
boundaryHint,
45+
};
46+
}
47+
48+
export function clearActiveNotebookTopic(state: AgenticodingState): void {
49+
state.activeNotebookTopic = null;
50+
state.activeNotebookTopicSource = null;
51+
state.pendingTopicBoundaryHint = null;
52+
}

state.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ export interface AgenticodingState {
1414
/** Monotonically increasing epoch, set on first notebook_write */
1515
epoch: number;
1616

17+
/** Current semantic frame for topic-aware spawn vs handoff decisions. */
18+
activeNotebookTopic: string | null;
19+
20+
/** Whether the current topic came from the human or the agent. */
21+
activeNotebookTopicSource: "human" | "agent" | null;
22+
23+
/** One-shot boundary cue consumed by the next LLM call after a topic change. */
24+
pendingTopicBoundaryHint: {
25+
from: string | null;
26+
to: string;
27+
source: "human" | "agent";
28+
} | null;
29+
1730
/** Last context usage percent from getContextUsage() */
1831
lastContextPercent: number | null;
1932

@@ -59,6 +72,9 @@ export function createState(): AgenticodingState {
5972
const state: AgenticodingState = {
6073
notebookPages: new Map(),
6174
epoch: 0,
75+
activeNotebookTopic: null,
76+
activeNotebookTopicSource: null,
77+
pendingTopicBoundaryHint: null,
6278
lastContextPercent: null,
6379
pendingHandoff: null,
6480
pendingRequestedHandoff: null,
@@ -89,6 +105,9 @@ export function resetState(state: AgenticodingState): void {
89105
state.childSessionEpoch++;
90106
state.notebookPages.clear();
91107
state.epoch = 0;
108+
state.activeNotebookTopic = null;
109+
state.activeNotebookTopicSource = null;
110+
state.pendingTopicBoundaryHint = null;
92111
state.lastContextPercent = null;
93112
state.pendingHandoff = null;
94113
state.pendingRequestedHandoff = null;

tui.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export const STATUS_KEY_CTX = "agenticoding-ctx";
2222
/** Status bar key for notebook page count. */
2323
export const STATUS_KEY_NOTEBOOK = "agenticoding-notebook";
2424

25-
/** Update TUI indicators: context usage, notebook count, warning widget. */
25+
/** Status bar key for the active notebook topic. */
26+
export const STATUS_KEY_TOPIC = "agenticoding-topic";
27+
28+
/** Update TUI indicators: context usage, notebook count, topic, warning widget. */
2629
export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState): void {
2730
if (!ctx.hasUI) return;
2831

@@ -45,11 +48,21 @@ export function updateIndicators(ctx: ExtensionContext, state: AgenticodingState
4548
: theme.fg("dim", "\u{1F4D2} 0"),
4649
);
4750

51+
// Active notebook topic — show a dim placeholder when unset so the frame is discoverable
52+
ctx.ui.setStatus(
53+
STATUS_KEY_TOPIC,
54+
state.activeNotebookTopic
55+
? `\u{1F9ED} ${state.activeNotebookTopic}`
56+
: theme.fg("dim", "\u{1F9ED} -"),
57+
);
58+
4859
// High-context warning widget (above editor)
4960
if (usage && usage.percent !== null && usage.percent >= 70) {
61+
const warning = state.activeNotebookTopic
62+
? `Context at ${Math.round(usage.percent)}% — use topic fit: same topic → spawn, different topic → handoff`
63+
: `Context at ${Math.round(usage.percent)}% — no active topic; handoff soon unless you can assign one cleanly`;
5064
ctx.ui.setWidget(WIDGET_KEY_WARNING, [
51-
theme.fg("error", "\u26A0 ") +
52-
theme.fg("warning", `Context at ${Math.round(usage.percent)}% — consider handoff`),
65+
theme.fg("error", "\u26A0 ") + theme.fg("warning", warning),
5366
]);
5467
} else {
5568
ctx.ui.setWidget(WIDGET_KEY_WARNING, undefined);

0 commit comments

Comments
 (0)