Skip to content

Commit 17823be

Browse files
Merge branch 'jaredlockhart:main' into main
2 parents bd266d5 + d15f609 commit 17823be

25 files changed

+897
-420
lines changed

Makefile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ PYTEST_ARGS = penny/tests/ -v
44
TEAM_RUFF_TARGETS = penny_team/
55
TEAM_PYTEST_ARGS = tests/ -v
66

7-
.PHONY: up prod kill build team-build fmt lint fix typecheck check pytest token migrate-test migrate-validate
7+
.PHONY: up prod kill build team-build browser-build fmt lint fix typecheck check pytest token migrate-test migrate-validate
88

99
# --- Docker Compose ---
1010

11-
up:
11+
up: browser-build
1212
GIT_COMMIT=$$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
1313
GIT_COMMIT_MESSAGE=$$(git log -1 --pretty=%B 2>/dev/null | tr '\n' ' ' | sed 's/ *$$//' || echo unknown) \
1414
SNAPSHOT=1 \
1515
docker compose --profile team up --build
1616

17-
prod:
17+
prod: browser-build
1818
GIT_COMMIT=$$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
1919
GIT_COMMIT_MESSAGE=$$(git log -1 --pretty=%B 2>/dev/null | tr '\n' ' ' | sed 's/ *$$//' || echo unknown) \
2020
SNAPSHOT=1 \
@@ -31,6 +31,9 @@ build:
3131
team-build:
3232
docker compose build team
3333

34+
browser-build:
35+
cd browser && npm install && npm run build
36+
3437
# Print a GitHub App installation token for use with gh CLI
3538
# Usage: GH_TOKEN=$(make token) gh pr create ...
3639
token:
@@ -76,6 +79,7 @@ check: $(if $(LOCAL),,build team-build)
7679
$(TEAM_RUN) ruff check $(TEAM_RUFF_TARGETS)
7780
$(TEAM_RUN) ty check $(TEAM_RUFF_TARGETS)
7881
$(TEAM_RUN) pytest $(TEAM_PYTEST_ARGS)
82+
cd browser && npm install --silent && npx tsc --noEmit
7983

8084
pytest: $(if $(LOCAL),,build team-build)
8185
$(RUN) pytest $(PYTEST_ARGS)

browser/package-lock.json

Lines changed: 11 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

browser/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
},
1111
"devDependencies": {
1212
"@types/firefox-webext-browser": "^143.0.0",
13+
"@types/turndown": "^5.0.6",
1314
"concurrently": "^9.1.2",
1415
"esbuild": "^0.27.4",
1516
"typescript": "^5.9.3",
1617
"web-ext": "^8.4.0"
1718
},
1819
"dependencies": {
1920
"@fortawesome/fontawesome-free": "^7.2.0",
20-
"defuddle": "^0.14.0"
21+
"defuddle": "^0.14.0",
22+
"turndown": "^7.2.2"
2123
}
2224
}

browser/src/background/background.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
ConnectionState as CS,
1010
type DomainAllowlist,
1111
type DomainPermissionEntry,
12-
MAX_PAGE_CONTEXT_CHARS,
1312
type PageContext,
1413
RECONNECT_DELAY_MS,
1514
THOUGHTS_POLL_INTERVAL_MS,
@@ -103,7 +102,7 @@ async function extractFromActiveTab(): Promise<void> {
103102
currentPageContext = {
104103
title: data.title,
105104
url: data.url,
106-
text: data.text.slice(0, MAX_PAGE_CONTEXT_CHARS),
105+
text: data.text,
107106
image: data.image,
108107
};
109108
broadcastPageInfo(data.title, data.url, favicon, data.image, true);
@@ -229,6 +228,8 @@ function connect(): void {
229228
domain: data.domain,
230229
url: data.url,
231230
});
231+
} else if (data.type === WsIn.PermissionDismiss) {
232+
broadcastToSidebar({ type: RuntimeMessageType.PermissionDismiss });
232233
}
233234
});
234235

browser/src/content/extract_text.ts

Lines changed: 28 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/**
2-
* Content script — extracts main content from the current page using Defuddle.
3-
* Falls back to CSS heuristics if Defuddle returns insufficient content.
2+
* Content script — extracts main content from the current page.
3+
* Uses Defuddle for article extraction, then Turndown for HTML → Markdown.
44
* Bundled with esbuild (not compiled by tsc) since content scripts can't use imports.
55
*/
66

7-
import { Defuddle } from "defuddle";
8-
import { extractKagiResults } from "./extractors/kagi";
7+
import Defuddle from "defuddle";
8+
import TurndownService from "turndown";
9+
10+
const turndown = new TurndownService({ headingStyle: "atx" });
911

1012
const MIN_CONTENT_LENGTH = 200;
1113
const MAX_CHARS = 50_000;
@@ -18,82 +20,32 @@ interface PageData {
1820
ready: boolean;
1921
}
2022

21-
function extractWithDefuddle(): string | null {
22-
try {
23-
const clone = document.cloneNode(true) as Document;
24-
const result = new Defuddle(clone, { url: location.href }).parse();
25-
const text = result.content
26-
? stripHtmlTags(result.content)
27-
: null;
28-
if (text && text.length >= MIN_CONTENT_LENGTH) {
29-
return text;
30-
}
31-
} catch {
32-
// Defuddle failed — fall through to heuristics
33-
}
34-
return null;
35-
}
36-
37-
function extractWithHeuristics(): string | null {
38-
const selectors = [
39-
"article",
40-
"main",
41-
'[role="main"]',
42-
"#content",
43-
".post-content",
44-
".article-content",
45-
".entry-content",
46-
];
23+
/** Domain-specific readiness locators. For JS-rendered pages, Defuddle may
24+
* extract too early and get page chrome instead of content. These selectors
25+
* gate extraction — if the selector isn't present yet, we return ready=false
26+
* so pollForContent retries until the real content has rendered. */
27+
const READINESS_LOCATORS: [match: (hostname: string) => boolean, selector: string][] = [
28+
[(h) => h.includes("kagi.com"), ".search-result"],
29+
];
4730

48-
for (const selector of selectors) {
49-
const el = document.querySelector(selector);
50-
if (el) {
51-
const text = (el as HTMLElement).innerText?.trim();
52-
if (text && text.length >= MIN_CONTENT_LENGTH) {
53-
return text;
54-
}
55-
}
31+
function findReadinessSelector(): string | null {
32+
for (const [match, selector] of READINESS_LOCATORS) {
33+
if (match(location.hostname)) return selector;
5634
}
5735
return null;
5836
}
5937

60-
function extractAllVisibleText(): string {
61-
const SKIP_TAGS = new Set([
62-
"SCRIPT", "STYLE", "NOSCRIPT", "SVG", "IFRAME",
63-
"NAV", "ASIDE", "FOOTER", "HEADER",
64-
]);
65-
66-
const chunks: string[] = [];
67-
const walker = document.createTreeWalker(
68-
document.body,
69-
NodeFilter.SHOW_TEXT,
70-
{
71-
acceptNode(node: Text): number {
72-
const parent = node.parentElement;
73-
if (!parent) return NodeFilter.FILTER_REJECT;
74-
if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
75-
const style = window.getComputedStyle(parent);
76-
if (style.display === "none" || style.visibility === "hidden") {
77-
return NodeFilter.FILTER_REJECT;
78-
}
79-
return NodeFilter.FILTER_ACCEPT;
80-
},
81-
},
82-
);
83-
84-
let node: Text | null;
85-
while ((node = walker.nextNode() as Text | null)) {
86-
const text = node.textContent?.trim();
87-
if (text) chunks.push(text);
38+
function extractWithDefuddle(): string | null {
39+
const clone = document.cloneNode(true) as Document;
40+
const result = new Defuddle(clone, { url: location.href }).parse();
41+
if (!result.content) return null;
42+
const text = turndown.turndown(result.content);
43+
if (text && text.length >= MIN_CONTENT_LENGTH) {
44+
return text;
8845
}
89-
return chunks.join("\n");
46+
return null;
9047
}
9148

92-
function stripHtmlTags(html: string): string {
93-
const div = document.createElement("div");
94-
div.innerHTML = html;
95-
return div.innerText || div.textContent || "";
96-
}
9749

9850
function extractMetaImage(): string {
9951
const selectors = [
@@ -110,24 +62,12 @@ function extractMetaImage(): string {
11062
}
11163

11264
function extract(): PageData {
113-
// Kagi pages must use the Kagi extractor — no fallbacks.
114-
// Returns ready=false when results haven't rendered yet;
115-
// pollForContent re-injects until ready.
116-
if (location.hostname.includes("kagi.com")) {
117-
const kagi = extractKagiResults();
118-
return {
119-
title: document.title,
120-
url: location.href,
121-
text: kagi ? kagi.slice(0, MAX_CHARS) : "",
122-
image: "",
123-
ready: kagi !== null,
124-
};
65+
const readinessSelector = findReadinessSelector();
66+
if (readinessSelector && !document.querySelector(readinessSelector)) {
67+
return { title: document.title, url: location.href, text: "", image: "", ready: false };
12568
}
12669

127-
const text =
128-
extractWithDefuddle() ??
129-
extractWithHeuristics() ??
130-
extractAllVisibleText();
70+
const text = extractWithDefuddle() ?? "Failed to extract page content";
13171

13272
return {
13373
title: document.title,

browser/src/protocol.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export type RuntimeMessageType =
238238
| "connection_state"
239239
| "permission_request"
240240
| "permission_response"
241+
| "permission_dismiss"
241242
| "page_info"
242243
| "thoughts_request"
243244
| "thoughts_response"
@@ -263,6 +264,7 @@ export const RuntimeMessageType = {
263264
ConnectionState: "connection_state",
264265
PermissionRequest: "permission_request",
265266
PermissionResponse: "permission_response",
267+
PermissionDismiss: "permission_dismiss",
266268
PageInfo: "page_info",
267269
ThoughtsRequest: "thoughts_request",
268270
ThoughtsResponse: "thoughts_response",
@@ -323,6 +325,11 @@ export interface RuntimePermissionResponse {
323325
allowed: boolean;
324326
}
325327

328+
/** Background → sidebar: dismiss any open permission dialog */
329+
export interface RuntimePermissionDismiss {
330+
type: typeof RuntimeMessageType.PermissionDismiss;
331+
}
332+
326333
/** Background → sidebar: current page info for the context toggle */
327334
export interface RuntimePageInfo {
328335
type: typeof RuntimeMessageType.PageInfo;
@@ -439,6 +446,7 @@ export type RuntimeMessage =
439446
| RuntimeConnectionState
440447
| RuntimePermissionRequest
441448
| RuntimePermissionResponse
449+
| RuntimePermissionDismiss
442450
| RuntimePageInfo
443451
| RuntimeThoughtsRequest
444452
| RuntimeThoughtsResponse
@@ -477,8 +485,6 @@ export interface PageContext {
477485
image: string;
478486
}
479487

480-
export const MAX_PAGE_CONTEXT_CHARS = 5_000;
481-
482488
// --- Tool constants ---
483489

484490
export const THOUGHTS_POLL_INTERVAL_MS = 300_000;

browser/src/sidebar/sidebar.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ function handleBackgroundMessage(message: RuntimeMessage): void {
404404
} else if (message.type === RuntimeMessageType.PermissionRequest) {
405405
if (activeView === "settings") showView("chat");
406406
showPermissionDialog(message.request_id, message.domain, message.url);
407+
} else if (message.type === RuntimeMessageType.PermissionDismiss) {
408+
document.getElementById("permission-dialog")?.classList.add("hidden");
407409
} else if (message.type === RuntimeMessageType.ThoughtCount) {
408410
const countEl = document.getElementById("nav-thoughts-count");
409411
if (countEl) countEl.textContent = message.count > 0 ? ` (${message.count})` : "";
@@ -610,6 +612,7 @@ function setTyping(active: boolean, content?: string): void {
610612
} else if (active && indicator && content) {
611613
indicator.className = isToolStatus ? "typing tool-status" : "typing";
612614
indicator.innerHTML = typingHTML(text);
615+
messagesEl.scrollTop = messagesEl.scrollHeight;
613616
} else if (!active && indicator) {
614617
indicator.remove();
615618
}

penny/penny/agents/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,13 @@ async def _run_agentic_loop(
357357
empty_retries: int = 0
358358
refusal_retries: int = 0
359359

360+
force_no_tools = False
361+
360362
for step in range(steps):
361363
logger.info("Agent step %d/%d", step + 1, steps)
362364

363365
is_final_step = step == steps - 1
364-
strip_tools = is_final_step and not self._keep_tools_on_final_step
366+
strip_tools = force_no_tools or (is_final_step and not self._keep_tools_on_final_step)
365367
step_tools = [] if strip_tools else tools
366368
if strip_tools:
367369
logger.debug("Final step — tools removed, model must produce text")
@@ -438,6 +440,7 @@ async def _run_agentic_loop(
438440
else:
439441
nudge = "Please provide your response."
440442
messages.append({"role": MessageRole.USER, "content": nudge})
443+
force_no_tools = True
441444
if not is_final_step:
442445
continue
443446
# On the final step, retry directly — can't extend a for-range loop

penny/penny/agents/notify.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ def _get_top_thoughts(self, user: str, n: int) -> list[Thought]:
569569
for pid, thoughts in by_pref.items()
570570
if not self._topic_on_cooldown(pid, last_notified, cutoff, cooldown)
571571
}
572-
ranked = sorted(eligible.keys(), key=lambda pid: last_notified.get(pid) or "")
572+
ranked = sorted(eligible.keys(), key=lambda pid: last_notified.get(pid) or datetime.min)
573573
slots = n - len(result)
574574
for pref_id in ranked[:slots]:
575575
result.append(eligible[pref_id][-1]) # most recent unnotified

penny/penny/channels/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,25 @@ async def close(self) -> None:
199199
"""Close the channel and cleanup resources."""
200200
pass
201201

202+
# --- Permission prompts (overridden by channels that support them) ---
203+
204+
async def handle_permission_prompt(
205+
self,
206+
request_id: str,
207+
domain: str,
208+
url: str,
209+
) -> None:
210+
"""Handle a permission prompt broadcast. Override in subclasses."""
211+
return # no-op default
212+
213+
async def handle_permission_dismiss(self, request_id: str) -> None:
214+
"""Handle a permission dismiss broadcast. Override in subclasses."""
215+
return # no-op default
216+
217+
async def handle_domain_permissions_changed(self) -> None:
218+
"""Handle domain permissions update. Override in subclasses."""
219+
return # no-op default
220+
202221
async def _fetch_attachments(self, message: IncomingMessage, raw_data: dict) -> IncomingMessage:
203222
"""
204223
Fetch attachment data for the message. Override in subclasses.

0 commit comments

Comments
 (0)