Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/server/src/engine/page-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/** Lines of page text for comparing two checks of a watched page. */
export function pageLines(text: string, limit = 2000): string[] {
const lines = new Set<string>();
for (const raw of text.split(/\r?\n/)) {
const line = raw.replace(/\s+/g, " ").trim().slice(0, 300);
if (line) lines.add(line);
if (lines.size >= limit) break;
}
return [...lines];
}

export type PageDiff = { added: string[]; updated: string[]; removed: string[] };

// Relative times ("posted 1 day ago" → "posted 2 days ago", "58 minutes ago" → "1 hour ago")
// tick on their own, so a line where only they changed is not news and is left out.
const relativeTime =
/\b(?:\d+|an?|one)\s+(?:sec(?:ond)?|min(?:ute)?|hour|hr|day|week|month|year)s?\s+ago\b|\bvor\s+(?:\d+|einer?|einem)\s+(?:sekunde|minute|stunde|tag|woche|monat|jahr)(?:e|en|n)?\b/giu;
const timeless = (line: string) => line.replace(relativeTime, "<time>");
// Any other number change (a price, stock count or version) is news, listed as an update.
const numberless = (line: string) =>
timeless(line)
.replace(/\d+(?:[.,]\d+)*/g, "#")
.replace(/(\p{L})s\b/gu, "$1")
.toLowerCase();

/** Whether any line was added, updated or removed. */
export function meaningfulPageDiff(diff: PageDiff) {
return diff.added.length > 0 || diff.updated.length > 0 || diff.removed.length > 0;
}

export function diffPage(previous: string[], current: string[]): PageDiff {
const before = new Set(previous);
const after = new Set(current);
const beforeTimeless = new Set(previous.map(timeless));
const afterTimeless = new Set(current.map(timeless));
const beforeNumberless = new Set(previous.map(numberless));
const afterNumberless = new Set(current.map(numberless));
const changed = current.filter(
(line) => !before.has(line) && !beforeTimeless.has(timeless(line)),
);
return {
added: changed.filter((line) => !beforeNumberless.has(numberless(line))),
updated: changed.filter((line) => beforeNumberless.has(numberless(line))),
removed: previous.filter(
(line) =>
!after.has(line) &&
!afterTimeless.has(timeless(line)) &&
!afterNumberless.has(numberless(line)),
),
};
}

/** A short "New / Updated / Removed" summary, or "" when no line changed. */
export function describePageDiff(diff: PageDiff) {
const section = (title: string, lines: string[], limit: number) => {
if (!lines.length) return [];
const shown = lines.slice(0, limit).map((line) => `• ${line.slice(0, 160)}`);
const more = lines.length > limit ? [`+${lines.length - limit} more`] : [];
return [`${title}:`, ...shown, ...more];
};
return [
...section("New", diff.added, 8),
...section("Updated", diff.updated, 4),
...section("Removed", diff.removed, 4),
].join("\n");
}

/** A one-line count for task results, such as "3 lines changed (2 new, 1 updated)". */
export function countPageDiff(diff: PageDiff) {
const total = diff.added.length + diff.updated.length + diff.removed.length;
if (!total) return "";
const parts = [
diff.added.length && `${diff.added.length} new`,
diff.updated.length && `${diff.updated.length} updated`,
diff.removed.length && `${diff.removed.length} removed`,
].filter(Boolean);
return `${total} line${total === 1 ? "" : "s"} changed (${parts.join(", ")})`;
}
37 changes: 33 additions & 4 deletions apps/server/src/engine/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ import { backgroundFailure } from "../log.ts";
import type { WorkspaceService } from "../workspace.ts";
import { analyzeSpending } from "./finance.ts";
import { executeModelTask } from "./model.ts";
import {
countPageDiff,
describePageDiff,
diffPage,
meaningfulPageDiff,
pageLines,
} from "./page-diff.ts";
import { LostLeaseError, type TaskContext, TaskWorker } from "./worker.ts";

const hash = (text: string) => createHash("sha256").update(text).digest("hex");
Expand Down Expand Up @@ -1030,7 +1037,22 @@ export class AgentService {
? text.toLowerCase().includes(monitor.value.toLowerCase())
: this.matchesPrice(text, Number(monitor.value));
const previouslyMatched = Boolean(task.state.matched);
const shouldNotify = matched && (monitor.condition === "change" || !previouslyMatched);
// For change watches, compare the page line by line with the previous check.
const lines = monitor.condition === "change" ? pageLines(observation.text) : [];
const previousPage =
matched && monitor.condition === "change"
? await this.db.get<{ hash?: string; lines: string[] }>(owner, "monitor-pages", monitor.id)
: null;
// Saved lines can run ahead of a lost task outcome; diff only against the committed baseline.
const diff =
previousPage && previousPage.hash === previousHash
? diffPage(previousPage.lines, lines)
: undefined;
// Only relative times changed ("3 minutes ago"): keep watching quietly.
const quiet = Boolean(diff && !meaningfulPageDiff(diff));
const shouldNotify =
matched && !quiet && (monitor.condition === "change" || !previouslyMatched);
const changes = diff && shouldNotify ? describePageDiff(diff) : "";
const nextCheckAt = new Date(Date.now() + monitor.intervalMinutes * 60000).toISOString();
await ctx.guard();
// Worker lease is checked before each publication; monitor control also invalidates that lease.
Expand All @@ -1049,20 +1071,25 @@ export class AgentService {
},
);
if (!savedMonitor) throw new LostLeaseError();
if (monitor.condition === "change")
await this.db.put(owner, "monitor-pages", { id: monitor.id, hash: currentHash, lines });
await ctx.event(
"observation",
previousHash ? "Checked for changes" : "Saved the first observation",
text.slice(0, 1000),
);
if (shouldNotify) {
await ctx.guard();
await ctx.event("result", "A meaningful change was found", text.slice(0, 500));
await ctx.event("result", "A meaningful change was found", changes || text.slice(0, 500));
}
const count = diff ? countPageDiff(diff) : "";
return {
status: "scheduled",
nextRunAt: nextCheckAt,
result: shouldNotify
? "Change found. A notification is ready."
? count
? `Change found: ${count}. A notification is ready.`
: "Change found. A notification is ready."
: "Watching. I'll check again on schedule.",
state: {
...task.state,
Expand All @@ -1074,7 +1101,9 @@ export class AgentService {
notice: shouldNotify
? {
title: monitor.title,
body: `Condition met at ${observation.url}: ${text.slice(0, 240)}`,
body: changes
? `Changed at ${observation.url}\n${changes}`
: `Condition met at ${observation.url}: ${text.slice(0, 240)}`,
key: `monitor:${monitor.id}:${currentHash}`,
}
: null,
Expand Down
85 changes: 85 additions & 0 deletions tests/agent-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,88 @@ test("live mode rejects sample sources and hides the fixture mutation endpoint",
await live.agent.stop();
}
});

test("a change watch alert lists the new and updated lines of the page", async () => {
await read("/sample-page", {
text: "AI jobs in Munich\nSiemens · Werkstudent AI · 2 openings\nBCG · Intern",
});
const monitor = await read<Monitor>(
"/monitors",
{
title: "Munich AI jobs",
url: "sample://availability",
condition: "change",
intervalMinutes: 1,
},
201,
);
await server.agent.worker.tick();
await read("/sample-page", {
text: "AI jobs in Munich\nSAP · Working Student AI Engineer\nSiemens · Werkstudent AI · 3 openings\nBCG · Intern",
});
await read(`/monitors/${monitor.id}/control`, { action: "check" });
await server.agent.worker.tick();
const [alert] = (await read<AgentNotification[]>("/notifications")).filter(
(item) => item.taskId === monitor.taskId,
);
assert.equal(
alert?.body,
"Changed at sample://availability\nNew:\n• SAP · Working Student AI Engineer\nUpdated:\n• Siemens · Werkstudent AI · 3 openings",
);
const { task } = await read<{ task: AgentTask }>(`/tasks/${monitor.taskId}`);
assert.equal(
task.result,
"Change found: 2 lines changed (1 new, 1 updated). A notification is ready.",
);
await read(`/monitors/${monitor.id}/control`, { action: "stop" });
});

test("a change watch stays quiet when only relative times change", async () => {
await read("/sample-page", { text: "Jobs\nSiemens · Werkstudent AI · 3 minutes ago" });
const monitor = await read<Monitor>(
"/monitors",
{ title: "Quiet jobs", url: "sample://availability", condition: "change", intervalMinutes: 1 },
201,
);
const alerts = async () =>
(await read<AgentNotification[]>("/notifications")).filter(
(item) => item.taskId === monitor.taskId,
);
await server.agent.worker.tick();
for (const text of [
"Jobs\nSiemens · Werkstudent AI · 58 minutes ago",
"Jobs\nSiemens · Werkstudent AI · 1 hour ago",
]) {
await read("/sample-page", { text });
await read(`/monitors/${monitor.id}/control`, { action: "check" });
await server.agent.worker.tick();
}
assert.equal((await alerts()).length, 0, "ticking timestamps are not news");
await read("/sample-page", {
text: "Jobs\nSAP · Working Student AI\nSiemens · Werkstudent AI · 2 hours ago",
});
await read(`/monitors/${monitor.id}/control`, { action: "check" });
await server.agent.worker.tick();
const [alert] = await alerts();
assert.match(alert?.body ?? "", /New:\n• SAP · Working Student AI/);
await read(`/monitors/${monitor.id}/control`, { action: "stop" });
});

test("a change watch alerts when only a price changes", async () => {
await read("/sample-page", { text: "Headphones\nPrice: $399.99\nOnly 3 left" });
const monitor = await read<Monitor>(
"/monitors",
{ title: "Headphones", url: "sample://availability", condition: "change", intervalMinutes: 1 },
201,
);
await server.agent.worker.tick();
await read("/sample-page", { text: "Headphones\nPrice: $279.99\nOnly 3 left" });
await read(`/monitors/${monitor.id}/control`, { action: "check" });
await server.agent.worker.tick();
const alerts = (await read<AgentNotification[]>("/notifications")).filter(
(item) => item.taskId === monitor.taskId,
);
assert.equal(alerts.length, 1, "a price change is news");
assert.equal(alerts[0]?.body, "Changed at sample://availability\nUpdated:\n• Price: $279.99");
await read(`/monitors/${monitor.id}/control`, { action: "stop" });
});
87 changes: 87 additions & 0 deletions tests/page-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
countPageDiff,
describePageDiff,
diffPage,
meaningfulPageDiff,
pageLines,
} from "../apps/server/src/engine/page-diff.ts";

test("page lines are trimmed, collapsed, deduplicated and bounded", () => {
assert.deepEqual(pageLines(" Jobs \n\n Siemens AI\r\nSiemens AI\n"), ["Jobs", "Siemens AI"]);
assert.equal(pageLines("a\nb\nc", 2).length, 2);
assert.equal(pageLines("x".repeat(500))[0].length, 300);
});

test("new lines are separated from lines where a number changed", () => {
const before = [
"AI jobs in Munich",
"Siemens · Werkstudent AI · 2 openings · posted 1 day ago",
"BCG · Intern",
];
const after = [
"AI jobs in Munich",
"SAP · Working Student AI Engineer",
"Siemens · Werkstudent AI · 3 openings · posted 2 days ago",
];
assert.deepEqual(diffPage(before, after), {
added: ["SAP · Working Student AI Engineer"],
updated: ["Siemens · Werkstudent AI · 3 openings · posted 2 days ago"],
removed: ["BCG · Intern"],
});
assert.deepEqual(diffPage(before, before), { added: [], updated: [], removed: [] });
});

test("price, stock, count and version changes are meaningful updates", () => {
for (const [before, after] of [
["Price: $399.99", "Price: $279.99"],
["Only 3 left", "Only 0 left"],
["Tickets available: 12", "Tickets available: 0"],
["Latest release 1.2.3", "Latest release 2.0.0"],
["1 comment", "2 comments"],
]) {
const diff = diffPage(["Shop", before], ["Shop", after]);
assert.deepEqual(diff, { added: [], updated: [after], removed: [] }, before);
assert.equal(meaningfulPageDiff(diff), true, before);
}
});

test("the change summary lists sections, clips long lists, and counts lines", () => {
const diff = {
added: Array.from({ length: 10 }, (_, i) => `Role ${i}`),
updated: ["Price $12"],
removed: [],
};
const text = describePageDiff(diff);
assert.match(text, /^New:\n• Role 0\n/);
assert.match(text, /\+2 more\nUpdated:\n• Price \$12$/);
assert.doesNotMatch(text, /Removed/);
assert.equal(countPageDiff(diff), "11 lines changed (10 new, 1 updated)");
assert.equal(countPageDiff({ added: ["a"], updated: [], removed: [] }), "1 line changed (1 new)");
assert.equal(describePageDiff({ added: [], updated: [], removed: [] }), "");
assert.equal(countPageDiff({ added: [], updated: [], removed: [] }), "");
});

test("lines where only a relative time changed are left out and are not meaningful", () => {
const before = [
"3 points by ada 58 minutes ago | hide",
"Vor 2 Stunden veröffentlicht",
"Show HN: A new tool",
];
const after = [
"3 points by ada 1 hour ago | hide",
"Vor einer Stunde veröffentlicht",
"Show HN: A new tool",
];
const timesOnly = diffPage(before, after);
assert.deepEqual(timesOnly, { added: [], updated: [], removed: [] });
assert.equal(meaningfulPageDiff(timesOnly), false);
assert.deepEqual(diffPage(before, ["4 points by ada 1 hour ago | hide", ...after.slice(1)]), {
added: [],
updated: ["4 points by ada 1 hour ago | hide"],
removed: [],
});
assert.equal(meaningfulPageDiff(diffPage(before, [...after, "Ask HN: Another post"])), true);
assert.equal(meaningfulPageDiff(diffPage(before, after.slice(0, 2))), true);
});
Loading