portable-agent-layer 0.98.0 → 0.99.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.98.0",
3
+ "version": "0.99.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,7 +80,7 @@
80
80
  "@commitlint/cli": "21.0.1",
81
81
  "@commitlint/config-conventional": "21.0.1",
82
82
  "@hughescr/stryker-bun-runner": "1.3.8",
83
- "@konvert7/klint": "0.34.0",
83
+ "@konvert7/klint": "0.35.1",
84
84
  "@opencode-ai/plugin": "latest",
85
85
  "@secretlint/secretlint-rule-preset-recommend": "13.0.2",
86
86
  "@semantic-release/changelog": "^6.0.3",
@@ -1,11 +1,11 @@
1
1
  /**
2
- * A commitment is an ISC: completing it keeps it, retiring it drops it, and a
3
- * snooze puts it off. Each change of state is one event, whichever surface made it.
2
+ * A commitment is an ISC: adding it opens it, completing it keeps it, retiring
3
+ * it drops it, and a snooze puts it off. Each change of state is one event, whichever surface made it.
4
4
  */
5
5
 
6
6
  import { appendEvent, logQuietly } from "./events";
7
7
 
8
- export type CommitmentOutcome = "kept" | "dropped" | "snoozed" | "reopened";
8
+ export type CommitmentOutcome = "opened" | "kept" | "dropped" | "snoozed" | "reopened";
9
9
 
10
10
  export function logCommitment(
11
11
  iscProject: string,
@@ -3,14 +3,27 @@
3
3
  * store. The name stays in the content store; the event carries only its kind.
4
4
  */
5
5
 
6
- import { appendEvent, logQuietly } from "./events";
6
+ import { type AppendOptions, appendEvent, logQuietly } from "./events";
7
+
8
+ export interface SeenEntity {
9
+ domain: string;
10
+ slug: string;
11
+ entityType: string;
12
+ name: string;
13
+ }
14
+
15
+ export const entityEventId = (entity: Pick<SeenEntity, "domain" | "slug">) =>
16
+ `entity:${entity.domain}:${entity.slug}`;
7
17
 
8
18
  export function logEntityFirstSeen(
9
- domain: string,
10
- entityType: string,
11
- name: string
19
+ entity: SeenEntity,
20
+ options: AppendOptions = {}
12
21
  ): void {
13
22
  logQuietly("entity_first_seen", () =>
14
- appendEvent("entity_first_seen", { domain, entityType }, { content: { name } })
23
+ appendEvent(
24
+ "entity_first_seen",
25
+ { domain: entity.domain, entityType: entity.entityType },
26
+ { id: entityEventId(entity), content: { name: entity.name }, ...options }
27
+ )
15
28
  );
16
29
  }
@@ -116,6 +116,9 @@ export const EVENT_FIELDS = {
116
116
  "aiCoAuthored",
117
117
  ],
118
118
  rating: ["rating", "source"],
119
+ project: ["change", "status"],
120
+ decision: ["words"],
121
+ promise: ["change", "dueInSec", "lateSec", "snoozes", "nags"],
119
122
  action: [
120
123
  "tool",
121
124
  "outcome",
@@ -178,6 +181,7 @@ export interface AppendOptions {
178
181
  session?: string | null;
179
182
  runtime?: AgentType | "git" | "unknown";
180
183
  attribution?: Partial<RecordAttribution>;
184
+ project?: string | null;
181
185
  channel?: string | null;
182
186
  cwd?: string;
183
187
  content?: Record<string, unknown>;
@@ -241,11 +245,16 @@ function envelope(type: EventType, options: AppendOptions): EventEnvelope {
241
245
  ...options.attribution,
242
246
  channel:
243
247
  options.channel !== undefined ? options.channel : process.env.PAL_CHANNEL || null,
244
- project: cwd ? projectOf(cwd) : null,
248
+ project: projectFor(options, cwd),
245
249
  cwd,
246
250
  };
247
251
  }
248
252
 
253
+ function projectFor(options: AppendOptions, cwd: string): string | null {
254
+ if (options.project !== undefined) return options.project;
255
+ return cwd ? projectOf(cwd) : null;
256
+ }
257
+
249
258
  /** Appends one event and returns its id, or null when the log is off or the write failed. */
250
259
  export function appendEvent(
251
260
  type: EventType,
@@ -28,6 +28,7 @@ import {
28
28
  } from "../../hooks/lib/projects";
29
29
  import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
30
30
  import { group, type Leaf, leaf, runCommand, UsageError } from "../lib/command";
31
+ import { decisionRow, emitHistoryRow, registrationRow } from "../lib/project-history";
31
32
  import {
32
33
  archiveLine,
33
34
  completeIsc,
@@ -169,6 +170,7 @@ function cmdCreate(given: string | undefined, values: CreateValues): undefined {
169
170
  ...(values["serves-note"] ? { serves_note: values["serves-note"] } : {}),
170
171
  };
171
172
  writeProject(project);
173
+ emitHistoryRow(registrationRow(project));
172
174
  ok({ created: true, project });
173
175
  }
174
176
 
@@ -258,11 +260,13 @@ function addDecision(name: string, decision: string, rationale: string): undefin
258
260
  const what = nonBlank(decision, "<decision>");
259
261
  const why = nonBlank(rationale, "<rationale...>");
260
262
  const p = requireProject(name);
261
- const date = new Date().toISOString().slice(0, 10);
263
+ const ts = now();
264
+ const date = ts.slice(0, 10);
262
265
  const line = `- ${date}: ${what} (${why})`;
263
266
  p.decisions = p.decisions ? `${p.decisions}\n${line}` : line;
264
- p.updated = now();
267
+ p.updated = ts;
265
268
  writeProject(p);
269
+ emitHistoryRow({ ...decisionRow(name, date, `${what} (${why})`), ts });
266
270
  ok({ updated: true, name });
267
271
  }
268
272
 
@@ -434,6 +438,7 @@ function cmdAddIsc(name: string, text: string): undefined {
434
438
  p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
435
439
  p.updated = now();
436
440
  writeProject(p);
441
+ logCommitment(name, id, "opened", "cli");
437
442
  ok({
438
443
  added: true,
439
444
  id,
@@ -31,7 +31,7 @@ import {
31
31
  writeFileSync,
32
32
  } from "node:fs";
33
33
  import { resolve } from "node:path";
34
- import { logEntityFirstSeen } from "../../hooks/lib/entity-events";
34
+ import { logEntityFirstSeen, type SeenEntity } from "../../hooks/lib/entity-events";
35
35
  import { ensureDir, paths } from "../../hooks/lib/paths";
36
36
 
37
37
  // --- Constants --------------------------------------------------------------
@@ -411,6 +411,15 @@ export function exists(domain: Domain, slug: string, rootDir?: string): boolean
411
411
  return existsSync(entityPath(domain, slug, rootDir));
412
412
  }
413
413
 
414
+ export function seenEntity(entity: Entity): SeenEntity {
415
+ return {
416
+ domain: entity.domain,
417
+ slug: entity.slug,
418
+ entityType: entity.frontmatter.type,
419
+ name: entity.frontmatter.title,
420
+ };
421
+ }
422
+
414
423
  export function save(entity: Entity, rootDir?: string): void {
415
424
  const p = entityPath(entity.domain, entity.slug, rootDir);
416
425
  const firstSeenInTheStore = !rootDir && !existsSync(p);
@@ -418,7 +427,7 @@ export function save(entity: Entity, rootDir?: string): void {
418
427
  writeFileSync(tmp, serialize(entity), "utf-8");
419
428
  renameSync(tmp, p);
420
429
  if (firstSeenInTheStore) {
421
- logEntityFirstSeen(entity.domain, entity.frontmatter.type, entity.frontmatter.title);
430
+ logEntityFirstSeen(seenEntity(entity));
422
431
  }
423
432
  }
424
433
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every entity in the knowledge store, first seen when it was created. The
3
+ * live event shares its id, so an entity already logged is skipped.
4
+ */
5
+
6
+ import { entityEventId, logEntityFirstSeen } from "../../../hooks/lib/entity-events";
7
+ import { type Entity, list, seenEntity } from "../../knowledge/lib";
8
+ import { emitUnseen } from "./rows";
9
+
10
+ interface EntityRow {
11
+ entity: Entity;
12
+ ts: string;
13
+ }
14
+
15
+ function emitEntity(row: EntityRow): void {
16
+ logEntityFirstSeen(seenEntity(row.entity), {
17
+ ts: row.ts,
18
+ session: null,
19
+ channel: null,
20
+ cwd: "",
21
+ attribution: { machine: "unknown", actor: "unknown", runtime: "unknown" },
22
+ });
23
+ }
24
+
25
+ /** Appends the entities not yet logged and returns how many. */
26
+ export function backfillEntities(known: Set<string>, since?: string): number {
27
+ const rows = list()
28
+ .filter((entity) => entity.frontmatter.created)
29
+ .map((entity) => ({ entity, ts: entity.frontmatter.created }));
30
+ return emitUnseen(rows, (row) => entityEventId(row.entity), emitEntity, known, since);
31
+ }
@@ -6,8 +6,11 @@
6
6
  import { loggedEventIds } from "../../../hooks/lib/events";
7
7
  import { readAllProjects } from "../../../hooks/lib/projects";
8
8
  import { eventLogEnabled } from "../../../hooks/lib/settings";
9
+ import { backfillEntities } from "./entities";
9
10
  import { backfillGit } from "./git";
10
11
  import { backfillLedger } from "./ledger";
12
+ import { backfillProjects } from "./projects";
13
+ import { backfillPromises } from "./promises";
11
14
  import { backfillRatings } from "./ratings";
12
15
  import { backfillSessions } from "./sessions";
13
16
  import { backfillTurns } from "./turns";
@@ -28,5 +31,8 @@ export function backfillEvents(since?: string): Record<string, number> {
28
31
  turns: backfillTurns(known, since),
29
32
  ratings: backfillRatings(known, since),
30
33
  ledger: backfillLedger(known, since),
34
+ projects: backfillProjects(known, since),
35
+ promises: backfillPromises(known, since),
36
+ entities: backfillEntities(known, since),
31
37
  };
32
38
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What each project's ISA still records about its own history. An ISC's
3
+ * opening was never dated, so it cannot be backfilled; a close already
4
+ * streamed live as a commitment is not logged twice.
5
+ */
6
+
7
+ import { eventsSince } from "../../../hooks/lib/events";
8
+ import { readAllProjects } from "../../../hooks/lib/projects";
9
+ import {
10
+ closedIscRows,
11
+ decisionRows,
12
+ emitHistoryRow,
13
+ type HistoryRow,
14
+ registrationRow,
15
+ } from "../project-history";
16
+ import { emitUnseen, recordedAttribution } from "./rows";
17
+
18
+ const commitmentKey = (fields: Record<string, unknown>) =>
19
+ `${fields.iscProject}:${fields.isc}:${fields.outcome}`;
20
+
21
+ function liveCommitments(): Set<string> {
22
+ return new Set(eventsSince("commitment", new Date(0)).map(commitmentKey));
23
+ }
24
+
25
+ function emitHistory(row: HistoryRow): void {
26
+ emitHistoryRow(row, recordedAttribution(row));
27
+ }
28
+
29
+ /** Appends the project history not yet logged and returns how many events. */
30
+ export function backfillProjects(known: Set<string>, since?: string): number {
31
+ const live = liveCommitments();
32
+ const rows = readAllProjects()
33
+ .flatMap((project) => [
34
+ registrationRow(project),
35
+ ...closedIscRows(project),
36
+ ...decisionRows(project),
37
+ ])
38
+ .filter((row) => row.type !== "commitment" || !live.has(commitmentKey(row.fields)));
39
+ return emitUnseen(rows, (row) => row.id, emitHistory, known, since);
40
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The promises the Discord bot nags about, from its store: each one made, and
3
+ * each one closed as done or dropped. The title is text, so it goes to the
4
+ * content store.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { appendEvent } from "../../../hooks/lib/events";
10
+ import { paths } from "../../../hooks/lib/paths";
11
+ import { emitUnseen } from "./rows";
12
+
13
+ interface StoredPromise {
14
+ id: number;
15
+ title: string;
16
+ due: string;
17
+ status: "open" | "done" | "dropped";
18
+ createdAt: string;
19
+ closedAt?: string;
20
+ project?: string;
21
+ snoozes: number;
22
+ nagCount: number;
23
+ }
24
+
25
+ interface PromiseRow {
26
+ id: string;
27
+ ts: string;
28
+ project: string | null;
29
+ fields: Record<string, unknown>;
30
+ title: string;
31
+ }
32
+
33
+ const secondsBetween = (from: string, to: string) =>
34
+ Math.round((Date.parse(to) - Date.parse(from)) / 1000);
35
+
36
+ function readPromises(): StoredPromise[] {
37
+ const file = resolve(paths.memory(), "commitments.json");
38
+ if (!existsSync(file)) return [];
39
+ try {
40
+ return JSON.parse(readFileSync(file, "utf-8")).items ?? [];
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function made(promise: StoredPromise): PromiseRow {
47
+ return {
48
+ id: `promise:${promise.id}:${promise.createdAt}:made`,
49
+ ts: promise.createdAt,
50
+ project: promise.project ?? null,
51
+ fields: { change: "made", dueInSec: secondsBetween(promise.createdAt, promise.due) },
52
+ title: promise.title,
53
+ };
54
+ }
55
+
56
+ function closed(promise: StoredPromise): PromiseRow[] {
57
+ if (promise.status === "open" || !promise.closedAt) return [];
58
+ return [
59
+ {
60
+ id: `promise:${promise.id}:${promise.createdAt}:${promise.status}`,
61
+ ts: promise.closedAt,
62
+ project: promise.project ?? null,
63
+ fields: {
64
+ change: promise.status,
65
+ lateSec: secondsBetween(promise.due, promise.closedAt),
66
+ snoozes: promise.snoozes,
67
+ nags: promise.nagCount,
68
+ },
69
+ title: promise.title,
70
+ },
71
+ ];
72
+ }
73
+
74
+ function emitPromise(row: PromiseRow): void {
75
+ appendEvent("promise", row.fields, {
76
+ id: row.id,
77
+ ts: row.ts,
78
+ session: null,
79
+ channel: null,
80
+ cwd: "",
81
+ project: row.project,
82
+ attribution: { machine: "unknown", actor: "unknown", runtime: "unknown" },
83
+ content: { title: row.title },
84
+ });
85
+ }
86
+
87
+ /** Appends the promises not yet logged and returns how many events. */
88
+ export function backfillPromises(known: Set<string>, since?: string): number {
89
+ const rows = readPromises().flatMap((promise) => [made(promise), ...closed(promise)]);
90
+ return emitUnseen(rows, (row) => row.id, emitPromise, known, since);
91
+ }
@@ -7,8 +7,8 @@
7
7
  * cursor stops where it starts.
8
8
  */
9
9
 
10
- import { readFileSync, statSync } from "node:fs";
11
- import { relative, sep } from "node:path";
10
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { relative, resolve, sep } from "node:path";
12
12
  import { appendEvent } from "../../../hooks/lib/events";
13
13
  import { cacheWritesOf, type TranscriptUsage } from "../transcript-usage";
14
14
  import { claudeTranscripts } from "../usage-buckets";
@@ -60,8 +60,7 @@ function parsed(text: string): ClaudeLine | null {
60
60
  }
61
61
  }
62
62
 
63
- function completeLinesFrom(filepath: string, offset: number) {
64
- const bytes = readFileSync(filepath);
63
+ function completeLinesFrom(bytes: Buffer, offset: number) {
65
64
  const end = bytes.lastIndexOf(10) + 1;
66
65
  if (end <= offset) return { lines: [], end: offset };
67
66
  const lines: { text: string; start: number }[] = [];
@@ -76,12 +75,8 @@ function completeLinesFrom(filepath: string, offset: number) {
76
75
  return { lines, end };
77
76
  }
78
77
 
79
- function settledMessages(
80
- filepath: string,
81
- offset: number,
82
- settled: boolean
83
- ): SettledRead {
84
- const { lines, end } = completeLinesFrom(filepath, offset);
78
+ function settledMessages(bytes: Buffer, offset: number, settled: boolean): SettledRead {
79
+ const { lines, end } = completeLinesFrom(bytes, offset);
85
80
  const groups = new Map<string, { start: number; line: ModelCallLine }>();
86
81
  let newest: string | undefined;
87
82
  for (const { text, start } of lines) {
@@ -136,21 +131,58 @@ function isSettled(filepath: string, now: Date): boolean {
136
131
  return now.getTime() - statSync(filepath).mtimeMs > SETTLE_MS;
137
132
  }
138
133
 
134
+ interface Transcript {
135
+ key: string;
136
+ bytes: () => Buffer;
137
+ settled: boolean;
138
+ }
139
+
140
+ function liveTranscripts(projectsDir: string, now: Date): Transcript[] {
141
+ return claudeTranscripts(projectsDir).map((filepath) => ({
142
+ key: relative(projectsDir, filepath).split(sep).join("/"),
143
+ bytes: () => readFileSync(filepath),
144
+ settled: isSettled(filepath, now),
145
+ }));
146
+ }
147
+
148
+ function copiesOfDeletedTranscripts(
149
+ copiesDir: string,
150
+ projectsDir: string
151
+ ): Transcript[] {
152
+ if (!existsSync(copiesDir)) return [];
153
+ return readdirSync(copiesDir, { recursive: true, encoding: "utf-8" })
154
+ .map((file) => file.split(sep).join("/"))
155
+ .filter((file) => file.endsWith(".jsonl.gz"))
156
+ .map((file) => file.slice(0, -".gz".length))
157
+ .filter((key) => !existsSync(resolve(projectsDir, key)))
158
+ .map((key) => ({
159
+ key,
160
+ bytes: () =>
161
+ Buffer.from(Bun.gunzipSync(readFileSync(resolve(copiesDir, `${key}.gz`)))),
162
+ settled: true,
163
+ }));
164
+ }
165
+
166
+ /** Reads each transcript from its cursor; one Claude Code already deleted is read from PAL's copy. */
139
167
  export function collectClaude(
140
168
  projectsDir: string,
141
169
  now: Date = new Date(),
142
- billingAt: (at: string) => Billing = () => UNKNOWN
170
+ billingAt: (at: string) => Billing = () => UNKNOWN,
171
+ copiesDir?: string
143
172
  ): number {
144
173
  const cursor = readCursor("claude");
145
174
  let emitted = 0;
146
- for (const filepath of claudeTranscripts(projectsDir)) {
147
- const key = relative(projectsDir, filepath).split(sep).join("/");
148
- const offset = Number(cursor[key] ?? 0);
149
- const read = settledMessages(filepath, offset, isSettled(filepath, now));
175
+ const transcripts = [
176
+ ...liveTranscripts(projectsDir, now),
177
+ ...(copiesDir ? copiesOfDeletedTranscripts(copiesDir, projectsDir) : []),
178
+ ];
179
+ for (const transcript of transcripts) {
180
+ const offset = Number(cursor[transcript.key] ?? 0);
181
+ const read = settledMessages(transcript.bytes(), offset, transcript.settled);
150
182
  if (read.resumeAt === offset) continue;
151
183
  for (const line of read.messages) emitModelCall(line, billingAt(line.timestamp));
152
184
  emitted += read.messages.length;
153
- cursor[key] = read.resumeAt;
185
+ cursor[transcript.key] = read.resumeAt;
154
186
  writeCursor("claude", cursor);
155
187
  }
156
188
  return emitted;
@@ -25,7 +25,8 @@ const COLLECTORS: Record<string, () => number> = {
25
25
  collectClaude(
26
26
  resolve(platform.claudeDir(), "projects"),
27
27
  new Date(),
28
- claudeBilling(claudeConfigFile())
28
+ claudeBilling(claudeConfigFile()),
29
+ resolve(paths.content(), "claude")
29
30
  ),
30
31
  opencode: () => collectOpencode(opencodeDatabase()),
31
32
  codex: () => collectCodex(platform.codexDir()),
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A project's history as events: its registration, its dated decisions, and
3
+ * each ISC it closed or retired as the commitment kept or dropped. The live
4
+ * commands and the backfill build the same rows, so both give one id.
5
+ */
6
+
7
+ import type { RecordAttribution } from "../../hooks/lib/actor";
8
+ import { appendEvent } from "../../hooks/lib/events";
9
+ import type { ProjectProgress } from "../../hooks/lib/projects";
10
+ import { parseIscs } from "./project-isc";
11
+
12
+ export interface HistoryRow {
13
+ id: string;
14
+ ts: string;
15
+ type: "project" | "commitment" | "decision";
16
+ project: string;
17
+ fields: Record<string, unknown>;
18
+ content?: Record<string, unknown>;
19
+ }
20
+
21
+ const ARCHIVE_HEADING = /^### (Archived|Retired) (\d{4}-\d{2}-\d{2})$/;
22
+ const DECISION_LINE = /^- (\d{4}-\d{2}-\d{2}): (.+)$/;
23
+
24
+ const dayStart = (day: string) => `${day}T00:00:00.000Z`;
25
+
26
+ function shortHash(text: string): string {
27
+ return new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 12);
28
+ }
29
+
30
+ export function registrationRow(
31
+ project: Pick<ProjectProgress, "name" | "created" | "status">
32
+ ): HistoryRow {
33
+ return {
34
+ id: `project:${project.name}:registered`,
35
+ ts: project.created,
36
+ type: "project",
37
+ project: project.name,
38
+ fields: { change: "registered", status: project.status },
39
+ };
40
+ }
41
+
42
+ export function decisionRow(project: string, day: string, text: string): HistoryRow {
43
+ return {
44
+ id: `decision:${project}:${day}:${shortHash(text)}`,
45
+ ts: dayStart(day),
46
+ type: "decision",
47
+ project,
48
+ fields: { words: text.split(/\s+/).length },
49
+ content: { text },
50
+ };
51
+ }
52
+
53
+ export function decisionRows(project: ProjectProgress): HistoryRow[] {
54
+ return (project.decisions ?? "").split("\n").flatMap((line) => {
55
+ const decision = DECISION_LINE.exec(line);
56
+ return decision ? [decisionRow(project.name, decision[1], decision[2])] : [];
57
+ });
58
+ }
59
+
60
+ export function closedIscRows(project: ProjectProgress): HistoryRow[] {
61
+ const rows: HistoryRow[] = [];
62
+ let heading: RegExpExecArray | null = null;
63
+ for (const line of (project.changelog ?? "").split("\n")) {
64
+ heading = ARCHIVE_HEADING.exec(line) ?? heading;
65
+ const [isc] = parseIscs(line);
66
+ if (!heading || !isc) continue;
67
+ const outcome = heading[1] === "Retired" ? "dropped" : "kept";
68
+ rows.push({
69
+ id: `commitment:${project.name}:${isc.id}:${outcome}:${heading[2]}`,
70
+ ts: dayStart(heading[2]),
71
+ type: "commitment",
72
+ project: project.name,
73
+ fields: {
74
+ iscProject: project.name,
75
+ isc: isc.id,
76
+ outcome,
77
+ surface: null,
78
+ snoozeDays: null,
79
+ },
80
+ content: { text: isc.text },
81
+ });
82
+ }
83
+ return rows;
84
+ }
85
+
86
+ export function emitHistoryRow(
87
+ row: HistoryRow,
88
+ attribution: Partial<RecordAttribution> = {}
89
+ ): string | null {
90
+ return appendEvent(row.type, row.fields, {
91
+ id: row.id,
92
+ ts: row.ts,
93
+ session: null,
94
+ channel: null,
95
+ cwd: "",
96
+ project: row.project,
97
+ attribution,
98
+ ...(row.content && { content: row.content }),
99
+ });
100
+ }