@p4code/cli 0.2.12 → 0.2.14

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/dist/bin.mjs CHANGED
@@ -99,6 +99,7 @@ import * as Logger from "effect/Logger";
99
99
  import * as NodeURL from "node:url";
100
100
  import { createOpencodeClient } from "@opencode-ai/sdk/v2";
101
101
  import { query } from "@anthropic-ai/claude-agent-sdk";
102
+ import { structuredPatch } from "diff";
102
103
  import * as Stdio from "effect/Stdio";
103
104
  import * as Sink from "effect/Sink";
104
105
  import "effect/Types";
@@ -237,7 +238,7 @@ const make$89 = () => {
237
238
  const layer$80 = Layer.sync(NetService, make$89);
238
239
  //#endregion
239
240
  //#region package.json
240
- var version = "0.2.12";
241
+ var version = "0.2.14";
241
242
  //#endregion
242
243
  //#region src/config.ts
243
244
  /**
@@ -10868,6 +10869,7 @@ var FeedSource = class extends Schema$1.Class("FeedSource")({
10868
10869
  id: FeedSourceId,
10869
10870
  name: Schema$1.String,
10870
10871
  kind: FeedSourceKind,
10872
+ category: Schema$1.optional(Schema$1.NullOr(Schema$1.String)),
10871
10873
  feedUrl: Schema$1.String,
10872
10874
  siteUrl: Schema$1.String,
10873
10875
  enabled: Schema$1.Boolean,
@@ -10893,6 +10895,7 @@ var FeedArticle = class extends Schema$1.Class("FeedArticle")({
10893
10895
  id: FeedArticleId,
10894
10896
  sourceId: FeedSourceId,
10895
10897
  sourceName: Schema$1.String,
10898
+ category: Schema$1.optional(Schema$1.NullOr(Schema$1.String)),
10896
10899
  canonicalUrl: Schema$1.String,
10897
10900
  discussionUrl: Schema$1.NullOr(Schema$1.String),
10898
10901
  title: Schema$1.String,
@@ -10908,6 +10911,7 @@ var FeedArticle = class extends Schema$1.Class("FeedArticle")({
10908
10911
  var FeedListInput = class extends Schema$1.Class("FeedListInput")({
10909
10912
  query: Schema$1.optional(Schema$1.String),
10910
10913
  sourceId: Schema$1.optional(FeedSourceId),
10914
+ category: Schema$1.optional(Schema$1.String),
10911
10915
  unreadOnly: Schema$1.optional(Schema$1.Boolean),
10912
10916
  startDate: Schema$1.optional(Schema$1.String),
10913
10917
  endDate: Schema$1.optional(Schema$1.String),
@@ -10921,6 +10925,7 @@ var FeedSourceUpsertInput = class extends Schema$1.Class("FeedSourceUpsertInput"
10921
10925
  id: Schema$1.optional(FeedSourceId),
10922
10926
  name: Schema$1.String,
10923
10927
  kind: FeedSourceKind,
10928
+ category: Schema$1.optional(Schema$1.NullOr(Schema$1.String)),
10924
10929
  feedUrl: Schema$1.String,
10925
10930
  siteUrl: Schema$1.String,
10926
10931
  enabled: Schema$1.Boolean
@@ -10943,7 +10948,8 @@ var FeedRefreshResult = class extends Schema$1.Class("FeedRefreshResult")({
10943
10948
  fetched: Schema$1.Number,
10944
10949
  summarized: Schema$1.Number,
10945
10950
  failedSources: Schema$1.Array(Schema$1.String),
10946
- refreshedAt: Schema$1.String
10951
+ refreshedAt: Schema$1.String,
10952
+ alreadyRunning: Schema$1.optional(Schema$1.Boolean)
10947
10953
  }) {};
10948
10954
  var FeedError = class extends Schema$1.TaggedErrorClass()("FeedError", {
10949
10955
  code: Schema$1.Literals([
@@ -17894,7 +17900,7 @@ const deleteAssetRoute = HttpRouter.add("DELETE", "/assets/:kind/:name", respond
17894
17900
  const outcome = yield* (yield* AgentAssetRepository).deleteByName(requested);
17895
17901
  return outcome._tag === "conflict" ? assetConflict(Option.getOrNull(outcome.current)) : HttpServerResponse.empty({ status: 204 });
17896
17902
  })));
17897
- const feedSourceColumns = `id, name, kind, feed_url AS "feedUrl", site_url AS "siteUrl",
17903
+ const feedSourceColumns$1 = `id, name, kind, category, feed_url AS "feedUrl", site_url AS "siteUrl",
17898
17904
  enabled, created_at AS "createdAt", updated_at AS "updatedAt"`;
17899
17905
  const toFeedSource = (row) => ({
17900
17906
  ...row,
@@ -17902,6 +17908,7 @@ const toFeedSource = (row) => ({
17902
17908
  });
17903
17909
  const FeedArticleWrite = Schema$1.Struct({
17904
17910
  sourceId: Schema$1.String,
17911
+ category: Schema$1.optional(Schema$1.NullOr(Schema$1.String)),
17905
17912
  canonicalUrl: Schema$1.String,
17906
17913
  discussionUrl: Schema$1.NullOr(Schema$1.String),
17907
17914
  title: Schema$1.String,
@@ -17927,7 +17934,7 @@ const FeedArticleWrite = Schema$1.Struct({
17927
17934
  });
17928
17935
  const listFeedSourcesRoute = HttpRouter.add("GET", "/feed/sources", respondToHubFailures(Effect.gen(function* () {
17929
17936
  yield* authenticateHubRequest();
17930
- const rows = yield* (yield* SqlClient.SqlClient).unsafe(`SELECT ${feedSourceColumns} FROM feed_sources ORDER BY name`);
17937
+ const rows = yield* (yield* SqlClient.SqlClient).unsafe(`SELECT ${feedSourceColumns$1} FROM feed_sources ORDER BY name`);
17931
17938
  return HttpServerResponse.jsonUnsafe(rows.map(toFeedSource));
17932
17939
  })));
17933
17940
  const upsertFeedSourceRoute = HttpRouter.add("PUT", "/feed/sources", respondToHubFailures(Effect.gen(function* () {
@@ -17937,11 +17944,11 @@ const upsertFeedSourceRoute = HttpRouter.add("PUT", "/feed/sources", respondToHu
17937
17944
  const crypto = yield* Crypto.Crypto;
17938
17945
  const id = input.id ?? FeedSourceId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie));
17939
17946
  const now = DateTime.formatIso(yield* DateTime.now);
17940
- yield* sql`INSERT INTO feed_sources (id, name, kind, feed_url, site_url, enabled, created_at, updated_at)
17941
- VALUES (${id}, ${input.name}, ${input.kind}, ${input.feedUrl}, ${input.siteUrl}, ${input.enabled ? 1 : 0}, ${now}, ${now})
17942
- ON CONFLICT(id) DO UPDATE SET name=excluded.name, kind=excluded.kind, feed_url=excluded.feed_url,
17947
+ yield* sql`INSERT INTO feed_sources (id, name, kind, category, feed_url, site_url, enabled, created_at, updated_at)
17948
+ VALUES (${id}, ${input.name}, ${input.kind}, ${input.category ?? null}, ${input.feedUrl}, ${input.siteUrl}, ${input.enabled ? 1 : 0}, ${now}, ${now})
17949
+ ON CONFLICT(id) DO UPDATE SET name=excluded.name, kind=excluded.kind, category=excluded.category, feed_url=excluded.feed_url,
17943
17950
  site_url=excluded.site_url, enabled=excluded.enabled, updated_at=excluded.updated_at`;
17944
- const rows = yield* sql.unsafe(`SELECT ${feedSourceColumns} FROM feed_sources WHERE id = ?`, [id]);
17951
+ const rows = yield* sql.unsafe(`SELECT ${feedSourceColumns$1} FROM feed_sources WHERE id = ?`, [id]);
17945
17952
  return HttpServerResponse.jsonUnsafe(toFeedSource(rows[0] ?? {}));
17946
17953
  })));
17947
17954
  const deleteFeedSourceRoute = HttpRouter.add("DELETE", "/feed/sources/:sourceId", respondToHubFailures(Effect.gen(function* () {
@@ -17954,7 +17961,7 @@ const listFeedRoute = HttpRouter.add("GET", "/feed/articles", respondToHubFailur
17954
17961
  yield* authenticateHubRequest();
17955
17962
  const sql = yield* SqlClient.SqlClient;
17956
17963
  const articles = yield* sql`
17957
- SELECT a.id, a.source_id AS "sourceId", s.name AS "sourceName", a.canonical_url AS "canonicalUrl",
17964
+ SELECT a.id, a.source_id AS "sourceId", s.name AS "sourceName", COALESCE(a.category, s.category) AS category, a.canonical_url AS "canonicalUrl",
17958
17965
  a.discussion_url AS "discussionUrl", a.title, a.author, a.published_at AS "publishedAt",
17959
17966
  a.fetched_at AS "fetchedAt", a.content_text AS "contentText", a.status,
17960
17967
  sm.model_selection AS "modelSelection", sm.summary, sm.highlights_json AS "highlightsJson",
@@ -17971,6 +17978,7 @@ const listFeedRoute = HttpRouter.add("GET", "/feed/articles", respondToHubFailur
17971
17978
  id: row.id,
17972
17979
  sourceId: row.sourceId,
17973
17980
  sourceName: row.sourceName,
17981
+ category: row.category ?? null,
17974
17982
  canonicalUrl: row.canonicalUrl,
17975
17983
  discussionUrl: row.discussionUrl ?? null,
17976
17984
  title: row.title,
@@ -18005,9 +18013,9 @@ const putFeedArticleRoute = HttpRouter.add("PUT", "/feed/articles/:articleId", r
18005
18013
  const body = yield* decodeOrInvalid(FeedArticleWrite, "Request body does not match feed article storage contract.")(yield* readJsonBody());
18006
18014
  const sql = yield* SqlClient.SqlClient;
18007
18015
  const id = params.articleId ?? "";
18008
- yield* sql`INSERT INTO feed_articles (id, source_id, canonical_url, discussion_url, title, author, published_at, fetched_at, content_text, status, content_revision)
18009
- VALUES (${id}, ${body.sourceId}, ${body.canonicalUrl}, ${body.discussionUrl ?? null}, ${body.title}, ${body.author ?? null}, ${body.publishedAt}, ${body.fetchedAt}, ${body.contentText ?? null}, ${body.status}, ${body.contentRevision})
18010
- ON CONFLICT(canonical_url) DO UPDATE SET discussion_url=excluded.discussion_url, title=excluded.title,
18016
+ yield* sql`INSERT INTO feed_articles (id, source_id, category, canonical_url, discussion_url, title, author, published_at, fetched_at, content_text, status, content_revision)
18017
+ VALUES (${id}, ${body.sourceId}, ${body.category ?? null}, ${body.canonicalUrl}, ${body.discussionUrl ?? null}, ${body.title}, ${body.author ?? null}, ${body.publishedAt}, ${body.fetchedAt}, ${body.contentText ?? null}, ${body.status}, ${body.contentRevision})
18018
+ ON CONFLICT(canonical_url) DO UPDATE SET category=excluded.category, discussion_url=excluded.discussion_url, title=excluded.title,
18011
18019
  author=excluded.author, published_at=excluded.published_at, fetched_at=excluded.fetched_at,
18012
18020
  content_text=excluded.content_text, status=excluded.status, content_revision=excluded.content_revision`;
18013
18021
  if (body.summary) {
@@ -18023,6 +18031,21 @@ const putFeedArticleRoute = HttpRouter.add("PUT", "/feed/articles/:articleId", r
18023
18031
  ON CONFLICT(article_id) DO UPDATE SET points=excluded.points, comments=excluded.comments, band=excluded.band, observed_at=excluded.observed_at`;
18024
18032
  return HttpServerResponse.empty({ status: 204 });
18025
18033
  })));
18034
+ const FeedEngagementWrite = Schema$1.Struct({
18035
+ points: Schema$1.NullOr(Schema$1.Number),
18036
+ comments: Schema$1.NullOr(Schema$1.Number),
18037
+ band: Schema$1.String,
18038
+ observedAt: Schema$1.String
18039
+ });
18040
+ const putFeedEngagementRoute = HttpRouter.add("PUT", "/feed/articles/:articleId/engagement", respondToHubFailures(Effect.gen(function* () {
18041
+ yield* authenticateHubRequest();
18042
+ const params = yield* HttpRouter.params;
18043
+ const engagement = yield* decodeOrInvalid(FeedEngagementWrite, "Request body does not match feed engagement contract.")(yield* readJsonBody());
18044
+ yield* (yield* SqlClient.SqlClient)`INSERT INTO feed_engagement (article_id, points, comments, band, observed_at)
18045
+ VALUES (${params.articleId ?? ""}, ${engagement.points ?? null}, ${engagement.comments ?? null}, ${engagement.band}, ${engagement.observedAt})
18046
+ ON CONFLICT(article_id) DO UPDATE SET points=excluded.points, comments=excluded.comments, band=excluded.band, observed_at=excluded.observed_at`;
18047
+ return HttpServerResponse.empty({ status: 204 });
18048
+ })));
18026
18049
  const markFeedReadRoute = HttpRouter.add("PUT", "/feed/read", respondToHubFailures(Effect.gen(function* () {
18027
18050
  yield* authenticateHubRequest();
18028
18051
  const input = yield* decodeOrInvalid(FeedMarkReadInput, "Request body does not match FeedMarkReadInput.")(yield* readJsonBody());
@@ -18061,6 +18084,13 @@ const acquireFeedLeaseRoute = HttpRouter.add("POST", "/feed/lease", respondToHub
18061
18084
  const rows = yield* sql`SELECT owner FROM feed_refresh_lease WHERE lease_key='refresh'`;
18062
18085
  return HttpServerResponse.jsonUnsafe({ acquired: rows[0]?.owner === body.owner });
18063
18086
  })));
18087
+ const renewFeedLeaseRoute = HttpRouter.add("PATCH", "/feed/lease", respondToHubFailures(Effect.gen(function* () {
18088
+ yield* authenticateHubRequest();
18089
+ const body = yield* readJsonBody();
18090
+ if (typeof body.owner !== "string" || typeof body.expiresAt !== "string") return yield* new InvalidHubRequest({ detail: "owner and expiresAt are required." });
18091
+ yield* (yield* SqlClient.SqlClient)`UPDATE feed_refresh_lease SET expires_at=${body.expiresAt} WHERE lease_key='refresh' AND owner=${body.owner}`;
18092
+ return HttpServerResponse.empty({ status: 204 });
18093
+ })));
18064
18094
  const releaseFeedLeaseRoute = HttpRouter.add("DELETE", "/feed/lease", respondToHubFailures(Effect.gen(function* () {
18065
18095
  yield* authenticateHubRequest();
18066
18096
  const body = yield* readJsonBody();
@@ -18068,7 +18098,7 @@ const releaseFeedLeaseRoute = HttpRouter.add("DELETE", "/feed/lease", respondToH
18068
18098
  yield* (yield* SqlClient.SqlClient)`DELETE FROM feed_refresh_lease WHERE lease_key='refresh' AND owner=${body.owner}`;
18069
18099
  return HttpServerResponse.empty({ status: 204 });
18070
18100
  })));
18071
- const layer$70 = Layer.mergeAll(healthRoute, listTasksRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, releaseFeedLeaseRoute);
18101
+ const layer$70 = Layer.mergeAll(healthRoute, listTasksRoute, getTaskRoute, createTaskRoute, putTaskRoute, updateTaskRoute, deleteTaskRoute, listAssetsRoute, getAssetRoute, putAssetRoute, deleteAssetRoute, listFeedSourcesRoute, upsertFeedSourceRoute, deleteFeedSourceRoute, listFeedRoute, putFeedArticleRoute, putFeedEngagementRoute, markFeedReadRoute, cleanupFeedRoute, setFeedRefreshedAtRoute, acquireFeedLeaseRoute, renewFeedLeaseRoute, releaseFeedLeaseRoute);
18072
18102
  //#endregion
18073
18103
  //#region src/hub/Migrations/001_Tasks.ts
18074
18104
  /**
@@ -18390,6 +18420,42 @@ var _010_DefaultFeedSources_default = Effect.gen(function* () {
18390
18420
  VALUES (${id}, ${name}, ${kind}, ${feedUrl}, ${siteUrl}, 1, ${now}, ${now})`;
18391
18421
  });
18392
18422
  //#endregion
18423
+ //#region src/hub/Migrations/011_YCombinatorFeedSource.ts
18424
+ var _011_YCombinatorFeedSource_default = Effect.gen(function* () {
18425
+ const sql = yield* SqlClient.SqlClient;
18426
+ const now = DateTime.formatIso(yield* DateTime.now);
18427
+ yield* sql`INSERT OR IGNORE INTO feed_sources
18428
+ (id, name, kind, feed_url, site_url, enabled, created_at, updated_at)
18429
+ VALUES ('y-combinator', 'Y Combinator', 'syndication',
18430
+ 'https://www.ycombinator.com/blog/rss/', 'https://www.ycombinator.com', 1, ${now}, ${now})`;
18431
+ yield* sql`UPDATE feed_sources
18432
+ SET site_url = 'https://news.ycombinator.com/news', updated_at = ${now}
18433
+ WHERE id = 'hacker-news'
18434
+ AND site_url IN ('http://news.ycombinator.com/news', 'https://news.ycombinator.com')`;
18435
+ });
18436
+ //#endregion
18437
+ //#region src/hub/Migrations/012_FeedSourceCategories.ts
18438
+ const defaultCategories = [
18439
+ ["hacker-news", "Tech"],
18440
+ ["y-combinator", "Startups"],
18441
+ ["techcrunch", "Startups"],
18442
+ ["the-verge", "Gadgets"],
18443
+ ["ars-technica", "Science"],
18444
+ ["wired", "Tech"]
18445
+ ];
18446
+ var _012_FeedSourceCategories_default = Effect.gen(function* () {
18447
+ const sql = yield* SqlClient.SqlClient;
18448
+ const now = DateTime.formatIso(yield* DateTime.now);
18449
+ yield* sql`ALTER TABLE feed_sources ADD COLUMN category TEXT`;
18450
+ for (const [id, category] of defaultCategories) yield* sql`UPDATE feed_sources SET category = ${category}, updated_at = ${now}
18451
+ WHERE id = ${id} AND category IS NULL`;
18452
+ });
18453
+ //#endregion
18454
+ //#region src/hub/Migrations/013_FeedArticleCategories.ts
18455
+ var _013_FeedArticleCategories_default = Effect.gen(function* () {
18456
+ yield* (yield* SqlClient.SqlClient)`ALTER TABLE feed_articles ADD COLUMN category TEXT`;
18457
+ });
18458
+ //#endregion
18393
18459
  //#region src/hub/Migrations.ts
18394
18460
  /**
18395
18461
  * Hub migrations.
@@ -18450,6 +18516,21 @@ const hubMigrationEntries = [
18450
18516
  10,
18451
18517
  "DefaultFeedSources",
18452
18518
  _010_DefaultFeedSources_default
18519
+ ],
18520
+ [
18521
+ 11,
18522
+ "YCombinatorFeedSource",
18523
+ _011_YCombinatorFeedSource_default
18524
+ ],
18525
+ [
18526
+ 12,
18527
+ "FeedSourceCategories",
18528
+ _012_FeedSourceCategories_default
18529
+ ],
18530
+ [
18531
+ 13,
18532
+ "FeedArticleCategories",
18533
+ _013_FeedArticleCategories_default
18453
18534
  ]
18454
18535
  ];
18455
18536
  const hubMigrationLoader = Migrator.fromRecord(Object.fromEntries(hubMigrationEntries.map(([id, name, migration]) => [`${id}_${name}`, migration])));
@@ -39534,6 +39615,7 @@ function projectActivityPayload(activity) {
39534
39615
  const changedFiles = [];
39535
39616
  collectChangedFiles(data, changedFiles, /* @__PURE__ */ new Set(), 0);
39536
39617
  if (changedFiles.length > 0) projectedData.files = changedFiles.map((path) => ({ path }));
39618
+ if (asTrimmedString$1(data.patch)) projectedData.patch = data.patch;
39537
39619
  const toolCategory = classifyToolCategoryFromToolData(data);
39538
39620
  if (toolCategory) projectedData.toolCategory = toolCategory;
39539
39621
  if ("toolCallId" in data) projectedData.toolCallId = data.toolCallId;
@@ -43527,6 +43609,133 @@ const make$50 = Effect.gen(function* () {
43527
43609
  });
43528
43610
  const layer$40 = Layer.effect(UsageService, make$50);
43529
43611
  //#endregion
43612
+ //#region src/feed/FeedStore.ts
43613
+ const storageFailure = (message) => new FeedError({
43614
+ code: "hub_unavailable",
43615
+ message
43616
+ });
43617
+ const feedSourceColumns = `id, name, kind, category, feed_url AS "feedUrl", site_url AS "siteUrl",
43618
+ enabled, created_at AS "createdAt", updated_at AS "updatedAt"`;
43619
+ const toStoredSource = (row) => ({
43620
+ ...row,
43621
+ enabled: Boolean(row.enabled)
43622
+ });
43623
+ /**
43624
+ * Local feed storage over the same SQLite schema the Hub uses, so a later Hub
43625
+ * connection can adopt the data without translation.
43626
+ */
43627
+ const makeLocalFeedStore = Effect.gen(function* () {
43628
+ const sql = yield* SqlClient.SqlClient;
43629
+ const run = (label, effect) => effect.pipe(Effect.mapError(() => storageFailure(`Local feed storage failed (${label}).`)));
43630
+ const listSources = () => run("sources", sql.unsafe(`SELECT ${feedSourceColumns} FROM feed_sources ORDER BY name`).pipe(Effect.map((rows) => rows.map(toStoredSource))));
43631
+ return {
43632
+ listSources,
43633
+ upsertSource: (input) => run("source upsert", Effect.gen(function* () {
43634
+ const id = input.id ?? FeedSourceId.make(crypto.randomUUID());
43635
+ const now = DateTime.formatIso(yield* DateTime.now);
43636
+ yield* sql`INSERT INTO feed_sources (id, name, kind, category, feed_url, site_url, enabled, created_at, updated_at)
43637
+ VALUES (${id}, ${input.name}, ${input.kind}, ${input.category ?? null}, ${input.feedUrl}, ${input.siteUrl}, ${input.enabled ? 1 : 0}, ${now}, ${now})
43638
+ ON CONFLICT(id) DO UPDATE SET name=excluded.name, kind=excluded.kind, category=excluded.category, feed_url=excluded.feed_url,
43639
+ site_url=excluded.site_url, enabled=excluded.enabled, updated_at=excluded.updated_at`;
43640
+ const rows = yield* sql.unsafe(`SELECT ${feedSourceColumns} FROM feed_sources WHERE id = ?`, [id]);
43641
+ return toStoredSource(rows[0] ?? {});
43642
+ })),
43643
+ deleteSource: (sourceId) => run("source delete", sql`DELETE FROM feed_sources WHERE id = ${sourceId}`),
43644
+ listArticles: () => run("articles", Effect.gen(function* () {
43645
+ const rows = yield* sql`
43646
+ SELECT a.id, a.source_id AS "sourceId", s.name AS "sourceName", COALESCE(a.category, s.category) AS category, a.canonical_url AS "canonicalUrl",
43647
+ a.discussion_url AS "discussionUrl", a.title, a.author, a.published_at AS "publishedAt",
43648
+ a.fetched_at AS "fetchedAt", a.content_text AS "contentText", a.status,
43649
+ sm.model_selection AS "modelSelection", sm.summary, sm.highlights_json AS "highlightsJson",
43650
+ sm.references_json AS "referencesJson", sm.summarized_at AS "summarizedAt",
43651
+ e.points, e.comments, COALESCE(e.band, 'unknown') AS band, COALESCE(e.observed_at, a.fetched_at) AS "observedAt",
43652
+ r.read_at AS "readAt"
43653
+ FROM feed_articles a JOIN feed_sources s ON s.id=a.source_id
43654
+ LEFT JOIN feed_summaries sm ON sm.article_id=a.id
43655
+ LEFT JOIN feed_engagement e ON e.article_id=a.id
43656
+ LEFT JOIN feed_read_state r ON r.article_id=a.id ORDER BY a.published_at DESC`;
43657
+ const metadata = yield* sql`SELECT value FROM feed_metadata WHERE key='refreshed_at'`;
43658
+ return {
43659
+ articles: rows.map((row) => ({
43660
+ id: row.id,
43661
+ sourceId: row.sourceId,
43662
+ sourceName: row.sourceName,
43663
+ category: row.category ?? null,
43664
+ canonicalUrl: row.canonicalUrl,
43665
+ discussionUrl: row.discussionUrl ?? null,
43666
+ title: row.title,
43667
+ author: row.author ?? null,
43668
+ publishedAt: row.publishedAt,
43669
+ fetchedAt: row.fetchedAt,
43670
+ contentText: row.contentText ?? null,
43671
+ status: row.status,
43672
+ readAt: row.readAt ?? null,
43673
+ summary: row.summary == null ? null : {
43674
+ articleId: row.id,
43675
+ modelSelection: row.modelSelection,
43676
+ summary: row.summary,
43677
+ highlights: JSON.parse(String(row.highlightsJson)),
43678
+ references: JSON.parse(String(row.referencesJson)),
43679
+ summarizedAt: row.summarizedAt
43680
+ },
43681
+ engagement: {
43682
+ articleId: row.id,
43683
+ points: row.points ?? null,
43684
+ comments: row.comments ?? null,
43685
+ band: row.band,
43686
+ observedAt: row.observedAt
43687
+ }
43688
+ })),
43689
+ refreshedAt: metadata[0]?.value ?? null
43690
+ };
43691
+ })),
43692
+ putArticle: (id, write) => run("article write", Effect.gen(function* () {
43693
+ yield* sql`INSERT INTO feed_articles (id, source_id, category, canonical_url, discussion_url, title, author, published_at, fetched_at, content_text, status, content_revision)
43694
+ VALUES (${id}, ${write.sourceId}, ${write.category ?? null}, ${write.canonicalUrl}, ${write.discussionUrl ?? null}, ${write.title}, ${write.author ?? null}, ${write.publishedAt}, ${write.fetchedAt}, ${write.contentText ?? null}, ${write.status}, ${write.contentRevision})
43695
+ ON CONFLICT(canonical_url) DO UPDATE SET category=excluded.category, discussion_url=excluded.discussion_url, title=excluded.title,
43696
+ author=excluded.author, published_at=excluded.published_at, fetched_at=excluded.fetched_at,
43697
+ content_text=excluded.content_text, status=excluded.status, content_revision=excluded.content_revision`;
43698
+ if (write.summary) {
43699
+ const summary = write.summary;
43700
+ yield* sql`INSERT INTO feed_summaries (article_id, model_selection, summary, highlights_json, references_json, summarized_at, content_revision)
43701
+ VALUES (${id}, ${summary.modelSelection}, ${summary.summary}, ${JSON.stringify(summary.highlights)}, ${JSON.stringify(summary.references)}, ${summary.summarizedAt}, ${write.contentRevision})
43702
+ ON CONFLICT(article_id) DO UPDATE SET model_selection=excluded.model_selection, summary=excluded.summary,
43703
+ highlights_json=excluded.highlights_json, references_json=excluded.references_json, summarized_at=excluded.summarized_at, content_revision=excluded.content_revision`;
43704
+ }
43705
+ const engagement = write.engagement;
43706
+ yield* sql`INSERT INTO feed_engagement (article_id, points, comments, band, observed_at)
43707
+ VALUES (${id}, ${engagement.points ?? null}, ${engagement.comments ?? null}, ${engagement.band}, ${engagement.observedAt})
43708
+ ON CONFLICT(article_id) DO UPDATE SET points=excluded.points, comments=excluded.comments, band=excluded.band, observed_at=excluded.observed_at`;
43709
+ })),
43710
+ putEngagement: (articleId, engagement) => run("engagement", sql`INSERT INTO feed_engagement (article_id, points, comments, band, observed_at)
43711
+ VALUES (${articleId}, ${engagement.points ?? null}, ${engagement.comments ?? null}, ${engagement.band}, ${engagement.observedAt})
43712
+ ON CONFLICT(article_id) DO UPDATE SET points=excluded.points, comments=excluded.comments, band=excluded.band, observed_at=excluded.observed_at`),
43713
+ markRead: (input) => run("read state", Effect.gen(function* () {
43714
+ const readAt = input.read ? DateTime.formatIso(yield* DateTime.now) : null;
43715
+ yield* sql`INSERT INTO feed_read_state (article_id, read_at) VALUES (${input.articleId}, ${readAt})
43716
+ ON CONFLICT(article_id) DO UPDATE SET read_at=excluded.read_at`;
43717
+ })),
43718
+ cleanup: (input) => run("cleanup", Effect.gen(function* () {
43719
+ const rows = yield* sql`SELECT COUNT(*) AS count FROM feed_articles WHERE published_at >= ${input.startDate} AND published_at <= ${input.endDate}`;
43720
+ const count = Number(rows[0]?.count ?? 0);
43721
+ if (input.confirm) yield* sql`DELETE FROM feed_articles WHERE published_at >= ${input.startDate} AND published_at <= ${input.endDate}`;
43722
+ return new FeedCleanupResult({
43723
+ count,
43724
+ deleted: input.confirm
43725
+ });
43726
+ })),
43727
+ setRefreshedAt: (refreshedAt) => run("refreshed-at", sql`INSERT INTO feed_metadata (key, value) VALUES ('refreshed_at', ${refreshedAt}) ON CONFLICT(key) DO UPDATE SET value=excluded.value`),
43728
+ acquireLease: (owner, expiresAt) => run("lease acquire", Effect.gen(function* () {
43729
+ const now = DateTime.formatIso(yield* DateTime.now);
43730
+ yield* sql`DELETE FROM feed_refresh_lease WHERE lease_key='refresh' AND expires_at <= ${now}`;
43731
+ yield* sql`INSERT INTO feed_refresh_lease (lease_key, owner, expires_at) VALUES ('refresh', ${owner}, ${expiresAt}) ON CONFLICT(lease_key) DO NOTHING`;
43732
+ return (yield* sql`SELECT owner FROM feed_refresh_lease WHERE lease_key='refresh'`)[0]?.owner === owner;
43733
+ })),
43734
+ renewLease: (owner, expiresAt) => run("lease renew", sql`UPDATE feed_refresh_lease SET expires_at=${expiresAt} WHERE lease_key='refresh' AND owner=${owner}`),
43735
+ releaseLease: (owner) => run("lease release", sql`DELETE FROM feed_refresh_lease WHERE lease_key='refresh' AND owner=${owner}`)
43736
+ };
43737
+ });
43738
+ //#endregion
43530
43739
  //#region src/feed/ArticleText.ts
43531
43740
  const BLOCK_TAGS = /<\/?(?:p|div|article|section|main|h[1-6]|li|br)[^>]*>/gi;
43532
43741
  function extractReadableArticleText(html) {
@@ -43549,6 +43758,40 @@ function hackerNewsItemId(discussionUrl) {
43549
43758
  return null;
43550
43759
  }
43551
43760
  }
43761
+ const HN_ITEM_FETCH_BATCH = 20;
43762
+ const HN_API_BASE = "https://hacker-news.firebaseio.com/v0";
43763
+ async function fetchHackerNewsFrontPage(storyCount = 300) {
43764
+ const idsResponse = await fetch(`${HN_API_BASE}/topstories.json`);
43765
+ if (!idsResponse.ok) throw new Error(String(idsResponse.status));
43766
+ const ids = (await idsResponse.json()).slice(0, storyCount);
43767
+ const entries = [];
43768
+ for (let index = 0; index < ids.length; index += HN_ITEM_FETCH_BATCH) {
43769
+ const batch = ids.slice(index, index + HN_ITEM_FETCH_BATCH);
43770
+ const items = await Promise.all(batch.map(async (id) => {
43771
+ try {
43772
+ const response = await fetch(`${HN_API_BASE}/item/${id}.json`);
43773
+ return response.ok ? await response.json() : null;
43774
+ } catch {
43775
+ return null;
43776
+ }
43777
+ }));
43778
+ for (const item of items) {
43779
+ if (!item || item.type !== "story" || item.dead || item.deleted || !item.title) continue;
43780
+ const discussionUrl = hackerNewsDiscussionUrl(String(item.id));
43781
+ entries.push({
43782
+ title: item.title,
43783
+ url: item.url ?? discussionUrl,
43784
+ author: item.by ?? null,
43785
+ publishedAt: (/* @__PURE__ */ new Date((item.time ?? 0) * 1e3)).toISOString(),
43786
+ content: null,
43787
+ discussionUrl,
43788
+ points: item.score ?? null,
43789
+ comments: item.descendants ?? null
43790
+ });
43791
+ }
43792
+ }
43793
+ return entries;
43794
+ }
43552
43795
  //#endregion
43553
43796
  //#region src/feed/SyndicationSource.ts
43554
43797
  const decodeEntities = (value) => value.replaceAll("<![CDATA[", "").replaceAll("]]>", "").replaceAll("&amp;", "&").replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", "\"").replaceAll("&#39;", "'").trim();
@@ -43611,10 +43854,103 @@ function parseSyndicationFeed(xml) {
43611
43854
  //#endregion
43612
43855
  //#region src/feed/FeedService.ts
43613
43856
  const FEED_REQUEST_TIMEOUT_MS = 2e4;
43614
- const FEED_REFRESH_LEASE_MS = 5 * 6e4;
43857
+ const FEED_REFRESH_LEASE_MS = 10 * 6e4;
43858
+ const MORNING_REFRESH_HOUR_DEFAULT = 7;
43859
+ /**
43860
+ * Daily refresh hour (0-23, server-local time), overridable with
43861
+ * P4CODE_FEED_REFRESH_HOUR. Set it to "off" to disable the daily refresh.
43862
+ * On-demand refreshes stay available from the Feed UI regardless.
43863
+ */
43864
+ const morningRefreshHour = (() => {
43865
+ const raw = process.env.P4CODE_FEED_REFRESH_HOUR;
43866
+ if (raw === "off") return null;
43867
+ const parsed = Number(raw ?? MORNING_REFRESH_HOUR_DEFAULT);
43868
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 23 ? parsed : MORNING_REFRESH_HOUR_DEFAULT;
43869
+ })();
43615
43870
  const FEED_FETCH_CONCURRENCY = 4;
43616
43871
  const FEED_MODEL = "gpt-5.6-luna";
43617
43872
  const FEED_FALLBACK_MODEL = "claude-sonnet-5";
43873
+ const sourceKind = (source) => source.kind ?? (source.feedUrl.includes("hnrss.org") || source.siteUrl.includes("news.ycombinator.com") ? "hacker_news" : "syndication");
43874
+ const normalizeFeedSource = (source) => new FeedSource({
43875
+ ...source,
43876
+ kind: sourceKind(source),
43877
+ category: source.category ?? null
43878
+ });
43879
+ const normalizeFeedArticle = (article) => new FeedArticle({
43880
+ ...article,
43881
+ discussionUrl: article.discussionUrl ?? null,
43882
+ author: article.author ?? null,
43883
+ category: article.category ?? null,
43884
+ summary: article.summary === null ? null : new FeedSummary({
43885
+ ...article.summary,
43886
+ ...normalizeStoredSummary(article.summary)
43887
+ }),
43888
+ engagement: new FeedEngagement({ ...article.engagement })
43889
+ });
43890
+ const normalizeFeedList = (result) => new FeedListResult({
43891
+ ...result,
43892
+ articles: result.articles.map(normalizeFeedArticle)
43893
+ });
43894
+ /**
43895
+ * Aggregate sources like the Hacker News front page mix many topics, so each
43896
+ * article gets its own category from the summarizer. The closed list keeps
43897
+ * the category filter stable; unknown model output falls back to null and the
43898
+ * source category takes over at read time.
43899
+ */
43900
+ const FEED_CATEGORIES = [
43901
+ "Tech",
43902
+ "AI",
43903
+ "Science",
43904
+ "Startups",
43905
+ "Gadgets",
43906
+ "Business",
43907
+ "Security",
43908
+ "Culture",
43909
+ "Other"
43910
+ ];
43911
+ const CATEGORY_LINE_PATTERN = /^\s*(?:#+\s*)?Category:\s*([A-Za-z ]+?)\s*$/im;
43912
+ const extractGeneratedCategory = (body) => {
43913
+ const match = body.match(CATEGORY_LINE_PATTERN);
43914
+ if (!match) return {
43915
+ category: null,
43916
+ body
43917
+ };
43918
+ return {
43919
+ category: FEED_CATEGORIES.find((candidate) => candidate.toLocaleLowerCase() === match[1]?.trim().toLocaleLowerCase()) ?? null,
43920
+ body: body.replace(CATEGORY_LINE_PATTERN, " ")
43921
+ };
43922
+ };
43923
+ const normalizeGeneratedSummary = (body) => {
43924
+ const highlights = (body.split(/(?:^|\s)##\s+Testing\b/i, 1)[0] ?? body).replace(/^\s*##\s+Summary\b\s*/i, "").split(/\n+|\s+[-*]\s+/).map((line) => line.trim().replace(/^[-*]\s+/, "")).filter(Boolean).slice(0, 3);
43925
+ return {
43926
+ summary: highlights.join(" "),
43927
+ highlights
43928
+ };
43929
+ };
43930
+ const normalizeStoredSummary = (summary) => {
43931
+ const normalized = normalizeGeneratedSummary(summary.summary);
43932
+ const highlights = summary.highlights.flatMap((highlight) => normalizeGeneratedSummary(highlight).highlights).filter(Boolean).slice(0, 3);
43933
+ return {
43934
+ summary: normalized.summary,
43935
+ highlights: highlights.length > 0 ? highlights : normalized.highlights
43936
+ };
43937
+ };
43938
+ /**
43939
+ * Promotion round-ups (promo codes, coupon lists, discount posts) are noise in
43940
+ * a news feed. Matched articles are skipped at fetch time and hidden from
43941
+ * reads, so already-stored ones disappear too. Patterns are deliberately
43942
+ * narrow: "strikes deal" or "sale of a company" must not match.
43943
+ */
43944
+ const PROMOTIONAL_TITLE_PATTERNS = [
43945
+ /promo codes?/i,
43946
+ /coupons?/i,
43947
+ /discount codes?/i,
43948
+ /\d+% off/i,
43949
+ /\$\d+ off/i,
43950
+ /half off/i,
43951
+ /save up to/i
43952
+ ];
43953
+ const isPromotionalArticle = (title) => PROMOTIONAL_TITLE_PATTERNS.some((pattern) => pattern.test(title));
43618
43954
  const fail = (code, message) => new FeedError({
43619
43955
  code,
43620
43956
  message
@@ -43643,171 +43979,229 @@ const jsonRequest = Effect.fn("FeedService.jsonRequest")(function* (url, token,
43643
43979
  const make$49 = Effect.gen(function* () {
43644
43980
  const hubLink = yield* HubLink;
43645
43981
  const providers = yield* ProviderInstanceRegistry;
43646
- const settings = Effect.fn("FeedService.settings")(function* () {
43647
- const state = yield* hubLink.current;
43648
- if (Option.isNone(state.settings)) return yield* fail("hub_unavailable", "Connect this environment to Hub to use Feed.");
43649
- return state.settings.value;
43650
- });
43651
- const call = Effect.fn("FeedService.call")(function* (path, init) {
43652
- const linked = yield* settings();
43653
- return yield* jsonRequest(`${linked.baseUrl}${path}`, linked.token, init);
43982
+ const config = yield* ServerConfig$1;
43983
+ const path = yield* Path.Path;
43984
+ const call = Effect.fn("FeedService.call")(function* (linked, requestPath, init) {
43985
+ return yield* jsonRequest(`${linked.baseUrl}${requestPath}`, linked.token, init);
43654
43986
  });
43655
- const sources = () => call("/feed/sources");
43656
- return {
43657
- list: Effect.fn("FeedService.list")(function* (input) {
43658
- const result = yield* call("/feed/articles");
43659
- const query = input.query?.trim().toLocaleLowerCase();
43660
- const articles = result.articles.filter((article) => input.sourceId === void 0 || article.sourceId === input.sourceId).filter((article) => !input.unreadOnly || article.readAt === null).filter((article) => input.startDate === void 0 || article.publishedAt >= input.startDate).filter((article) => input.endDate === void 0 || article.publishedAt <= input.endDate).filter((article) => !query || `${article.title}\n${article.summary?.summary ?? ""}\n${article.summary?.highlights.join(" ") ?? ""}`.toLocaleLowerCase().includes(query)).toSorted((a, b) => input.sort === "oldest" ? a.publishedAt.localeCompare(b.publishedAt) : b.publishedAt.localeCompare(a.publishedAt));
43661
- return {
43662
- ...result,
43663
- articles
43664
- };
43665
- }),
43666
- refresh: Effect.fn("FeedService.refresh")(function* () {
43667
- const owner = crypto.randomUUID();
43668
- const refreshedAt = (/* @__PURE__ */ new Date()).toISOString();
43669
- if (!(yield* call("/feed/lease", {
43670
- method: "POST",
43671
- body: JSON.stringify({
43672
- owner,
43673
- expiresAt: new Date(Date.now() + FEED_REFRESH_LEASE_MS).toISOString()
43674
- })
43675
- })).acquired) return {
43676
- fetched: 0,
43677
- summarized: 0,
43678
- failedSources: [],
43679
- refreshedAt
43680
- };
43681
- const existing = yield* call("/feed/articles");
43682
- const existingByUrl = new Map(existing.articles.map((article) => [article.canonicalUrl, article]));
43683
- const enabled = (yield* sources()).filter((source) => source.enabled);
43684
- let fetched = 0;
43685
- let summarized = 0;
43686
- const failedSources = [];
43687
- yield* Effect.forEach(enabled, (source) => Effect.gen(function* () {
43688
- const entries = parseSyndicationFeed(yield* Effect.tryPromise({
43689
- try: () => fetch(source.feedUrl).then((response) => {
43690
- if (!response.ok) throw new Error(String(response.status));
43691
- return response.text();
43692
- }),
43693
- catch: () => fail("fetch_failed", `Could not fetch ${source.name}.`)
43694
- }));
43695
- for (const entry of entries) {
43696
- const current = existingByUrl.get(entry.url);
43697
- let contentText = entry.content ? extractReadableArticleText(entry.content) : null;
43698
- if (contentText === null) contentText = yield* Effect.tryPromise({
43699
- try: () => fetch(entry.url).then((response) => response.ok ? response.text() : "").then(extractReadableArticleText),
43700
- catch: () => null
43701
- });
43702
- const revision = contentText ?? `${entry.title}:${entry.publishedAt}`;
43703
- if (current?.contentText === contentText && current.summary !== null) continue;
43704
- const id = current?.id ?? FeedArticleId.make(crypto.randomUUID());
43705
- const hnId = source.kind === "hacker_news" ? hackerNewsItemId(entry.discussionUrl) : null;
43706
- const hnEngagement = hnId === null ? null : yield* Effect.tryPromise({
43707
- try: () => fetch(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(hnId)}.json`).then((response) => response.ok ? response.json() : null),
43708
- catch: () => null
43709
- });
43710
- let summary = null;
43711
- let status = "summary_unavailable";
43712
- if (contentText !== null) {
43713
- const instances = yield* providers.listInstances;
43714
- const candidates = [{
43715
- instance: instances.find((instance) => instance.driverKind === "codex" && instance.enabled),
43716
- model: FEED_MODEL
43717
- }, {
43718
- instance: instances.find((instance) => instance.driverKind === "claudeAgent" && instance.enabled),
43719
- model: FEED_FALLBACK_MODEL
43720
- }];
43721
- for (const candidate of candidates) {
43722
- if (!candidate.instance) continue;
43723
- const generated = yield* candidate.instance.textGeneration.generatePrContent({
43724
- cwd: process.cwd(),
43725
- baseBranch: "feed",
43726
- headBranch: "article",
43727
- commitSummary: entry.title,
43728
- diffSummary: "Summarize this article for a dense technology news feed. Return concise summary in body.",
43729
- diffPatch: contentText.slice(0, 24e3),
43730
- modelSelection: {
43731
- instanceId: ProviderInstanceId.make(candidate.instance.instanceId),
43732
- model: candidate.model
43733
- }
43734
- }).pipe(Effect.option);
43735
- if (Option.isNone(generated)) continue;
43736
- const body = generated.value.body.trim();
43737
- summary = {
43738
- modelSelection: `${candidate.instance.instanceId}:${candidate.model}`,
43739
- summary: body,
43740
- highlights: body.split(/(?<=[.!?])\s+/).filter(Boolean).slice(0, 3),
43741
- references: [entry.url],
43742
- summarizedAt: refreshedAt
43743
- };
43744
- status = "ready";
43745
- summarized += 1;
43746
- break;
43747
- }
43748
- if (summary === null) status = "summary_failed";
43749
- }
43750
- yield* call(`/feed/articles/${encodeURIComponent(id)}`, {
43751
- method: "PUT",
43752
- body: JSON.stringify({
43753
- sourceId: source.id,
43754
- canonicalUrl: entry.url,
43755
- discussionUrl: hnId ? hackerNewsDiscussionUrl(hnId) : null,
43756
- title: entry.title,
43757
- author: entry.author,
43758
- publishedAt: entry.publishedAt,
43759
- fetchedAt: refreshedAt,
43760
- contentText,
43761
- status,
43762
- contentRevision: revision,
43763
- summary,
43764
- engagement: hnEngagement === null ? {
43765
- points: null,
43766
- comments: null,
43767
- band: "unknown",
43768
- observedAt: refreshedAt
43769
- } : {
43770
- points: hnEngagement.score ?? 0,
43771
- comments: hnEngagement.descendants ?? 0,
43772
- band: hackerNewsEngagementBand(hnEngagement.score ?? 0, hnEngagement.descendants ?? 0),
43773
- observedAt: refreshedAt
43774
- }
43775
- })
43776
- });
43777
- fetched += 1;
43778
- }
43779
- }).pipe(Effect.catch(() => Effect.sync(() => {
43780
- failedSources.push(source.name);
43781
- }))), { concurrency: FEED_FETCH_CONCURRENCY });
43782
- yield* call("/feed/refreshed-at", {
43783
- method: "PUT",
43784
- body: JSON.stringify({ refreshedAt })
43785
- });
43786
- yield* call("/feed/lease", {
43787
- method: "DELETE",
43788
- body: JSON.stringify({ owner })
43789
- });
43790
- return {
43791
- fetched,
43792
- summarized,
43793
- failedSources,
43794
- refreshedAt
43795
- };
43796
- }),
43797
- sources,
43798
- upsertSource: (input) => call("/feed/sources", {
43987
+ const makeHubFeedStore = (linked) => ({
43988
+ listSources: () => call(linked, "/feed/sources"),
43989
+ upsertSource: (input) => call(linked, "/feed/sources", {
43799
43990
  method: "PUT",
43800
43991
  body: JSON.stringify(input)
43801
43992
  }),
43802
- deleteSource: (input) => call(`/feed/sources/${encodeURIComponent(input.sourceId)}`, { method: "DELETE" }),
43803
- markRead: (input) => call("/feed/read", {
43993
+ deleteSource: (sourceId) => call(linked, `/feed/sources/${encodeURIComponent(sourceId)}`, { method: "DELETE" }),
43994
+ listArticles: () => call(linked, "/feed/articles"),
43995
+ putArticle: (id, write) => call(linked, `/feed/articles/${encodeURIComponent(id)}`, {
43996
+ method: "PUT",
43997
+ body: JSON.stringify(write)
43998
+ }),
43999
+ putEngagement: (articleId, engagement) => call(linked, `/feed/articles/${encodeURIComponent(articleId)}/engagement`, {
44000
+ method: "PUT",
44001
+ body: JSON.stringify(engagement)
44002
+ }).pipe(Effect.asVoid),
44003
+ markRead: (input) => call(linked, "/feed/read", {
43804
44004
  method: "PUT",
43805
44005
  body: JSON.stringify(input)
43806
44006
  }),
43807
- cleanup: (input) => call("/feed/cleanup", {
44007
+ cleanup: (input) => call(linked, "/feed/cleanup", {
43808
44008
  method: "POST",
43809
44009
  body: JSON.stringify(input)
43810
- })
44010
+ }).pipe(Effect.map((result) => new FeedCleanupResult({ ...result }))),
44011
+ setRefreshedAt: (refreshedAt) => call(linked, "/feed/refreshed-at", {
44012
+ method: "PUT",
44013
+ body: JSON.stringify({ refreshedAt })
44014
+ }).pipe(Effect.asVoid),
44015
+ acquireLease: (owner, expiresAt) => call(linked, "/feed/lease", {
44016
+ method: "POST",
44017
+ body: JSON.stringify({
44018
+ owner,
44019
+ expiresAt
44020
+ })
44021
+ }).pipe(Effect.map((lease) => lease.acquired)),
44022
+ renewLease: (owner, expiresAt) => call(linked, "/feed/lease", {
44023
+ method: "PATCH",
44024
+ body: JSON.stringify({
44025
+ owner,
44026
+ expiresAt
44027
+ })
44028
+ }).pipe(Effect.asVoid),
44029
+ releaseLease: (owner) => call(linked, "/feed/lease", {
44030
+ method: "DELETE",
44031
+ body: JSON.stringify({ owner })
44032
+ }).pipe(Effect.asVoid)
44033
+ });
44034
+ const localContext = yield* Layer.build(makeHubPersistenceLive(path.join(config.stateDir, "feed.sqlite"))).pipe(Effect.orDie);
44035
+ const localStore = yield* makeLocalFeedStore.pipe(Effect.provide(localContext));
44036
+ const store = Effect.fn("FeedService.store")(function* () {
44037
+ const state = yield* hubLink.current;
44038
+ return Option.isSome(state.settings) ? makeHubFeedStore(state.settings.value) : localStore;
44039
+ });
44040
+ const sources = () => store().pipe(Effect.flatMap((feedStore) => feedStore.listSources()), Effect.map((items) => items.map(normalizeFeedSource)));
44041
+ const list = Effect.fn("FeedService.list")(function* (input) {
44042
+ const feedStore = yield* store();
44043
+ const result = normalizeFeedList(yield* feedStore.listArticles());
44044
+ const enabledSourceIds = new Set((yield* feedStore.listSources()).filter((source) => source.enabled).map((source) => source.id));
44045
+ const query = input.query?.trim().toLocaleLowerCase();
44046
+ const articles = result.articles.filter((article) => enabledSourceIds.has(article.sourceId)).filter((article) => !isPromotionalArticle(article.title)).filter((article) => input.sourceId === void 0 || article.sourceId === input.sourceId).filter((article) => input.category === void 0 || article.category === input.category).filter((article) => !input.unreadOnly || article.readAt === null).filter((article) => input.startDate === void 0 || article.publishedAt >= input.startDate).filter((article) => input.endDate === void 0 || article.publishedAt <= input.endDate).filter((article) => !query || `${article.title}\n${article.summary?.summary ?? ""}\n${article.summary?.highlights.join(" ") ?? ""}`.toLocaleLowerCase().includes(query)).toSorted((a, b) => input.sort === "oldest" ? a.publishedAt.localeCompare(b.publishedAt) : b.publishedAt.localeCompare(a.publishedAt));
44047
+ return new FeedListResult({
44048
+ ...result,
44049
+ articles
44050
+ });
44051
+ });
44052
+ const refresh = Effect.fn("FeedService.refresh")(function* () {
44053
+ const feedStore = yield* store();
44054
+ const owner = crypto.randomUUID();
44055
+ const refreshedAt = (/* @__PURE__ */ new Date()).toISOString();
44056
+ if (!(yield* feedStore.acquireLease(owner, new Date(Date.now() + FEED_REFRESH_LEASE_MS).toISOString()))) return new FeedRefreshResult({
44057
+ fetched: 0,
44058
+ summarized: 0,
44059
+ failedSources: [],
44060
+ refreshedAt,
44061
+ alreadyRunning: true
44062
+ });
44063
+ const existing = normalizeFeedList(yield* feedStore.listArticles());
44064
+ const existingByUrl = new Map(existing.articles.map((article) => [article.canonicalUrl, article]));
44065
+ const enabled = (yield* sources()).filter((source) => source.enabled);
44066
+ let fetched = 0;
44067
+ let summarized = 0;
44068
+ const failedSources = [];
44069
+ yield* Effect.forEach(enabled, (source) => Effect.gen(function* () {
44070
+ const entries = source.kind === "hacker_news" ? yield* Effect.tryPromise({
44071
+ try: () => fetchHackerNewsFrontPage(),
44072
+ catch: () => fail("fetch_failed", `Could not fetch ${source.name}.`)
44073
+ }) : parseSyndicationFeed(yield* Effect.tryPromise({
44074
+ try: () => fetch(source.feedUrl).then((response) => {
44075
+ if (!response.ok) throw new Error(String(response.status));
44076
+ return response.text();
44077
+ }),
44078
+ catch: () => fail("fetch_failed", `Could not fetch ${source.name}.`)
44079
+ }));
44080
+ for (const entry of entries) {
44081
+ if (isPromotionalArticle(entry.title)) continue;
44082
+ const current = existingByUrl.get(entry.url);
44083
+ const hnId = source.kind === "hacker_news" ? hackerNewsItemId(entry.discussionUrl) : null;
44084
+ const hnEngagement = hnId === null || entry.points !== void 0 ? null : yield* Effect.tryPromise({
44085
+ try: () => fetch(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(hnId)}.json`).then((response) => response.ok ? response.json() : null),
44086
+ catch: () => null
44087
+ });
44088
+ const points = entry.points ?? hnEngagement?.score ?? null;
44089
+ const comments = entry.comments ?? hnEngagement?.descendants ?? null;
44090
+ const engagement = points === null && comments === null ? {
44091
+ points: null,
44092
+ comments: null,
44093
+ band: "unknown",
44094
+ observedAt: refreshedAt
44095
+ } : {
44096
+ points: points ?? 0,
44097
+ comments: comments ?? 0,
44098
+ band: hackerNewsEngagementBand(points ?? 0, comments ?? 0),
44099
+ observedAt: refreshedAt
44100
+ };
44101
+ let contentText = entry.content ? extractReadableArticleText(entry.content) : null;
44102
+ if (contentText === null) contentText = yield* Effect.tryPromise({
44103
+ try: () => fetch(entry.url).then((response) => response.ok ? response.text() : "").then(extractReadableArticleText),
44104
+ catch: () => null
44105
+ });
44106
+ const revision = contentText ?? `${entry.title}:${entry.publishedAt}`;
44107
+ if (current?.contentText === contentText && current.summary !== null) {
44108
+ if (engagement.band !== "unknown") yield* feedStore.putEngagement(current.id, engagement).pipe(Effect.ignore);
44109
+ continue;
44110
+ }
44111
+ const id = current?.id ?? FeedArticleId.make(crypto.randomUUID());
44112
+ let summary = null;
44113
+ let status = "summary_unavailable";
44114
+ let articleCategory = null;
44115
+ if (contentText !== null) {
44116
+ const instances = yield* providers.listInstances;
44117
+ const candidates = [{
44118
+ instance: instances.find((instance) => instance.driverKind === "codex" && instance.enabled),
44119
+ model: FEED_MODEL
44120
+ }, {
44121
+ instance: instances.find((instance) => instance.driverKind === "claudeAgent" && instance.enabled),
44122
+ model: FEED_FALLBACK_MODEL
44123
+ }];
44124
+ for (const candidate of candidates) {
44125
+ if (!candidate.instance) continue;
44126
+ const generated = yield* candidate.instance.textGeneration.generatePrContent({
44127
+ cwd: process.cwd(),
44128
+ baseBranch: "feed",
44129
+ headBranch: "article",
44130
+ commitSummary: entry.title,
44131
+ diffSummary: `Summarize this article for a dense news feed. Return a concise summary in the body. Start the body with exactly one line "Category: <name>" where <name> is one of: ${FEED_CATEGORIES.join(", ")}.`,
44132
+ diffPatch: contentText.slice(0, 24e3),
44133
+ modelSelection: {
44134
+ instanceId: ProviderInstanceId.make(candidate.instance.instanceId),
44135
+ model: candidate.model
44136
+ }
44137
+ }).pipe(Effect.option);
44138
+ if (Option.isNone(generated)) continue;
44139
+ const categorized = extractGeneratedCategory(generated.value.body);
44140
+ const normalized = normalizeGeneratedSummary(categorized.body);
44141
+ if (!normalized.summary) continue;
44142
+ articleCategory = categorized.category;
44143
+ summary = {
44144
+ modelSelection: `${candidate.instance.instanceId}:${candidate.model}`,
44145
+ summary: normalized.summary,
44146
+ highlights: normalized.highlights,
44147
+ references: [entry.url],
44148
+ summarizedAt: refreshedAt
44149
+ };
44150
+ status = "ready";
44151
+ summarized += 1;
44152
+ break;
44153
+ }
44154
+ if (summary === null) status = "summary_failed";
44155
+ }
44156
+ yield* feedStore.putArticle(id, {
44157
+ sourceId: source.id,
44158
+ category: articleCategory,
44159
+ canonicalUrl: entry.url,
44160
+ discussionUrl: hnId ? hackerNewsDiscussionUrl(hnId) : null,
44161
+ title: entry.title,
44162
+ author: entry.author,
44163
+ publishedAt: entry.publishedAt,
44164
+ fetchedAt: refreshedAt,
44165
+ contentText,
44166
+ status,
44167
+ contentRevision: revision,
44168
+ summary,
44169
+ engagement
44170
+ });
44171
+ fetched += 1;
44172
+ yield* feedStore.renewLease(owner, new Date(Date.now() + FEED_REFRESH_LEASE_MS).toISOString()).pipe(Effect.ignore);
44173
+ }
44174
+ }).pipe(Effect.catch(() => Effect.sync(() => {
44175
+ failedSources.push(source.name);
44176
+ }))), { concurrency: FEED_FETCH_CONCURRENCY });
44177
+ yield* feedStore.setRefreshedAt(refreshedAt);
44178
+ yield* feedStore.releaseLease(owner);
44179
+ return new FeedRefreshResult({
44180
+ fetched,
44181
+ summarized,
44182
+ failedSources,
44183
+ refreshedAt
44184
+ });
44185
+ });
44186
+ const morningRefreshDaemon = (hour) => Effect.gen(function* () {
44187
+ while (true) {
44188
+ const now = /* @__PURE__ */ new Date();
44189
+ const next = new Date(now);
44190
+ next.setHours(hour, 0, 0, 0);
44191
+ if (next.getTime() <= now.getTime()) next.setDate(next.getDate() + 1);
44192
+ yield* Effect.sleep(Duration.millis(next.getTime() - now.getTime()));
44193
+ yield* refresh().pipe(Effect.ignore);
44194
+ }
44195
+ });
44196
+ if (morningRefreshHour !== null) yield* Effect.forkScoped(morningRefreshDaemon(morningRefreshHour));
44197
+ return {
44198
+ list,
44199
+ refresh,
44200
+ sources,
44201
+ upsertSource: (input) => store().pipe(Effect.flatMap((feedStore) => feedStore.upsertSource(input)), Effect.map(normalizeFeedSource)),
44202
+ deleteSource: (input) => store().pipe(Effect.flatMap((feedStore) => feedStore.deleteSource(input.sourceId))),
44203
+ markRead: (input) => store().pipe(Effect.flatMap((feedStore) => feedStore.markRead(input))),
44204
+ cleanup: (input) => store().pipe(Effect.flatMap((feedStore) => feedStore.cleanup(input)))
43811
44205
  };
43812
44206
  });
43813
44207
  const unavailable = () => Effect.fail(fail("hub_unavailable", "Feed service is unavailable."));
@@ -65991,6 +66385,58 @@ function classifyToolItemType(toolName) {
65991
66385
  if (normalized.includes("image")) return "image_view";
65992
66386
  return "dynamic_tool_call";
65993
66387
  }
66388
+ const FILE_CHANGE_PATCH_CONTEXT_LINES = 3;
66389
+ /** Oversized patches are dropped rather than shipped with every timeline row. */
66390
+ const FILE_CHANGE_PATCH_MAX_LENGTH = 2e4;
66391
+ function ensureTrailingNewline(value) {
66392
+ return value.endsWith("\n") ? value : `${value}\n`;
66393
+ }
66394
+ function unifiedEditPatch(filePath, oldText, newText) {
66395
+ if (oldText === newText) return;
66396
+ const patch = structuredPatch(`a/${filePath}`, `b/${filePath}`, ensureTrailingNewline(oldText), ensureTrailingNewline(newText), void 0, void 0, { context: FILE_CHANGE_PATCH_CONTEXT_LINES });
66397
+ if (patch.hunks.length === 0) return;
66398
+ const lines = [`--- a/${filePath}`, `+++ b/${filePath}`];
66399
+ for (const hunk of patch.hunks) {
66400
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
66401
+ for (const line of hunk.lines) lines.push(line);
66402
+ }
66403
+ return `${lines.join("\n")}\n`;
66404
+ }
66405
+ /**
66406
+ * A unified diff for the edits one file-change tool call carries. Only
66407
+ * Edit-style inputs produce one: `old_string`/`new_string`, or a
66408
+ * MultiEdit-style `edits` array of such pairs. Write ships full content with
66409
+ * no previous version to diff against, so its row keeps the plain path list.
66410
+ *
66411
+ * The snippet-local diff is exact for the strings the call replaces; hunk line
66412
+ * numbers are relative to the snippet rather than the file.
66413
+ */
66414
+ function fileChangePatchForToolInput(itemType, input) {
66415
+ if (itemType !== "file_change") return;
66416
+ const filePath = readString(input.file_path) ?? readString(input.path);
66417
+ if (!filePath) return;
66418
+ const pairs = [];
66419
+ if (typeof input.old_string === "string" && typeof input.new_string === "string") pairs.push({
66420
+ oldText: input.old_string,
66421
+ newText: input.new_string
66422
+ });
66423
+ else if (Array.isArray(input.edits)) for (const edit of input.edits) {
66424
+ if (edit === null || typeof edit !== "object") continue;
66425
+ const editRecord = edit;
66426
+ if (typeof editRecord.old_string === "string" && typeof editRecord.new_string === "string") pairs.push({
66427
+ oldText: editRecord.old_string,
66428
+ newText: editRecord.new_string
66429
+ });
66430
+ }
66431
+ const sections = [];
66432
+ for (const pair of pairs) {
66433
+ const section = unifiedEditPatch(filePath, pair.oldText, pair.newText);
66434
+ if (section !== void 0) sections.push(section);
66435
+ }
66436
+ if (sections.length === 0) return;
66437
+ const patch = sections.join("");
66438
+ return patch.length <= FILE_CHANGE_PATCH_MAX_LENGTH ? patch : void 0;
66439
+ }
65994
66440
  function isReadOnlyToolName(toolName) {
65995
66441
  const normalized = toolName.toLowerCase();
65996
66442
  return normalized === "read" || normalized.includes("read file") || normalized.includes("view") || normalized.includes("grep") || normalized.includes("glob") || normalized.includes("search");
@@ -66866,6 +67312,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
66866
67312
  return;
66867
67313
  }
66868
67314
  for (const [index, tool] of context.inFlightTools.entries()) {
67315
+ const flushedInputPatch = fileChangePatchForToolInput(tool.itemType, tool.input);
66869
67316
  const toolStamp = yield* makeEventStamp();
66870
67317
  yield* offerRuntimeEvent({
66871
67318
  type: "item.completed",
@@ -66883,7 +67330,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
66883
67330
  data: {
66884
67331
  toolCallId: tool.itemId,
66885
67332
  toolName: tool.toolName,
66886
- input: tool.input
67333
+ input: tool.input,
67334
+ ...flushedInputPatch !== void 0 ? { patch: flushedInputPatch } : {}
66887
67335
  }
66888
67336
  },
66889
67337
  providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
@@ -67128,6 +67576,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
67128
67576
  lastEmittedInputFingerprint: nextFingerprint
67129
67577
  };
67130
67578
  context.inFlightTools.set(event.index, nextTool);
67579
+ const streamedInputPatch = fileChangePatchForToolInput(nextTool.itemType, nextTool.input);
67131
67580
  const stamp = yield* makeEventStamp();
67132
67581
  yield* offerRuntimeEvent({
67133
67582
  type: "item.updated",
@@ -67145,7 +67594,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
67145
67594
  data: {
67146
67595
  toolCallId: nextTool.itemId,
67147
67596
  toolName: nextTool.toolName,
67148
- input: nextTool.input
67597
+ input: nextTool.input,
67598
+ ...streamedInputPatch !== void 0 ? { patch: streamedInputPatch } : {}
67149
67599
  }
67150
67600
  },
67151
67601
  providerRefs: nativeProviderRefs(context, { providerItemId: nextTool.itemId }),
@@ -67198,6 +67648,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
67198
67648
  ...inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}
67199
67649
  };
67200
67650
  context.inFlightTools.set(index, tool);
67651
+ const startedInputPatch = fileChangePatchForToolInput(tool.itemType, toolInput);
67201
67652
  const stamp = yield* makeEventStamp();
67202
67653
  yield* offerRuntimeEvent({
67203
67654
  type: "item.started",
@@ -67215,7 +67666,8 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
67215
67666
  data: {
67216
67667
  toolCallId: tool.itemId,
67217
67668
  toolName: tool.toolName,
67218
- input: toolInput
67669
+ input: toolInput,
67670
+ ...startedInputPatch !== void 0 ? { patch: startedInputPatch } : {}
67219
67671
  }
67220
67672
  },
67221
67673
  providerRefs: nativeProviderRefs(context, { providerItemId: tool.itemId }),
@@ -67250,10 +67702,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (claudeSettin
67250
67702
  const [index, tool] = toolEntry;
67251
67703
  const itemStatus = toolResult.isError ? "failed" : "completed";
67252
67704
  const toolUseResult = readClaudeToolUseResult(message);
67705
+ const resultInputPatch = fileChangePatchForToolInput(tool.itemType, tool.input);
67253
67706
  const toolData = {
67254
67707
  toolCallId: tool.itemId,
67255
67708
  toolName: tool.toolName,
67256
67709
  input: tool.input,
67710
+ ...resultInputPatch !== void 0 ? { patch: resultInputPatch } : {},
67257
67711
  result: toolResult.block
67258
67712
  };
67259
67713
  const updatedStamp = yield* makeEventStamp();