@hraness/peopleblade 0.1.2 → 0.2.2

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/README.md CHANGED
@@ -15,7 +15,7 @@ Installation includes the pinned runtime dependencies Zod 4.4.3 and Effect 3.22.
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- bun add --global @hraness/peopleblade@0.1.2
18
+ bun add --global @hraness/peopleblade@0.2.2
19
19
  peopleblade --version
20
20
  peopleblade init
21
21
  peopleblade stats --json
@@ -37,44 +37,95 @@ authorized devices, `peopleblade cloud revoke DEVICE_ID` for a lost device, and
37
37
  token. The signed-in dashboard provides the lost-device recovery path when no CLI
38
38
  token remains available.
39
39
 
40
- ### Optional Wrench-backed sources
40
+ ### Optional Ghostget-backed sources
41
41
 
42
- The package does not install Wrench, browser automation, KB, native model runtimes,
42
+ The package does not install Ghostget, browser automation, KB, native model runtimes,
43
43
  or provider credentials. Beeper, Google, and WhatsApp live sync commands delegate
44
- through a separately reviewed `wrench` executable on `PATH` (or the absolute
45
- `PEOPLEBLADE_WRENCH_EXECUTABLE` path). PeopleBlade bundles only the pinned Wrench
46
- 0.16.8 client validators needed to fence requests and receipts; it does not bundle
47
- or silently download Wrench's execution runtime.
44
+ through a separately reviewed `ghostget` executable on `PATH` (or the absolute
45
+ `PEOPLEBLADE_GHOSTGET_EXECUTABLE` path). PeopleBlade bundles only the pinned Ghostget
46
+ 0.17.1 client validators needed to fence requests and receipts; it does not bundle
47
+ or silently download Ghostget's execution runtime.
48
48
 
49
- PeopleBlade 0.1.2 contains the reviewed Wrench-backed Beeper, Google, and WhatsApp
49
+ `PEOPLEBLADE_WRENCH_EXECUTABLE` remains a compatibility alias for the explicit
50
+ path. When both variables are set, they must name the same executable. The
51
+ bridge still requires exact Ghostget 0.17.1 before invoking a provider operation.
52
+
53
+ PeopleBlade 0.2.2 contains the reviewed Ghostget-backed Beeper, Google, and WhatsApp
50
54
  live sync commands. Running them requires the separately installed public
51
- `@hraness/wrench` 0.16.8 package. Beeper support is bounded to account-aware
55
+ `@hraness/ghostget` 0.17.1 package. Beeper support is bounded to account-aware
52
56
  `contacts.list@3` reads, `contacts.search@1`, `messaging.search@2`, and the separate
53
57
  body-free interaction exporter. `contacts.search@1` executes the official Beeper
54
- CLI 0.6.2 executable pinned by Wrench; `contacts.list@3` and `messaging.search@2`
55
- use Wrench's bounded Desktop-loopback read paths. The exporter is macOS arm64 only
58
+ CLI 0.6.2 executable pinned by Ghostget; `contacts.list@3` and `messaging.search@2`
59
+ use Ghostget's bounded Desktop-loopback read paths. The exporter is macOS arm64 only
56
60
  and reports lower-bound interaction counts from complete one-to-one conversations
57
61
  only. See
58
62
  [peopleblade.com/sources](https://peopleblade.com/sources) for the complete provider
59
63
  guide. A database previously synced by PeopleBlade 0.1.1 through Wrench 0.16.7
60
64
  must run `peopleblade beeper rebind --auth beeper-main --confirm --json` once after
61
- installing Wrench 0.16.8. The command appends reviewed metadata for the same source
65
+ installing Ghostget 0.17.1. The command appends reviewed metadata for the same source
62
66
  identity only after both complete account inventories match. Do not force-bind
63
- Wrench unless its authenticated Desktop target changed. PeopleBlade calls no
64
- Beeper interface directly and exposes none of Wrench's Beeper send or action
67
+ Ghostget unless its authenticated Desktop target changed. PeopleBlade calls no
68
+ Beeper interface directly and exposes none of Ghostget's Beeper send or action
65
69
  operations. Archive imports, Apple Contacts, iMessage, notes, identity review,
66
- backups, and optional PeopleBlade cloud commands do not require Wrench.
70
+ backups, and optional PeopleBlade cloud commands do not require Ghostget.
71
+
72
+ ## Browse and edit locally
73
+
74
+ ```bash
75
+ peopleblade capabilities --json
76
+ peopleblade query --search "Ada" --sort name --direction asc --json
77
+ peopleblade people show 12 --json
78
+ peopleblade ui
79
+ ```
80
+
81
+ `capabilities` is offline discovery, not proof that a provider is configured.
82
+ `query` supports source, email, phone and do-not-contact filters, up to 100 rows
83
+ per page, source facets, and `nextOffset` continuation. Pages are a live view;
84
+ imports and identity reviews can move rows between requests. `list` retains its
85
+ legacy shape for existing clients.
86
+
87
+ `ui` starts a local workspace on a random `127.0.0.1` port. Open the printed URL
88
+ and select its owner-only access file. The capability stays in tab memory; the
89
+ page loads no remote assets and performs no provider or cloud calls. Ctrl+C
90
+ stops the workspace and invalidates access. A process running as your OS user
91
+ can access your local files; this is not a sandbox against that process.
92
+
93
+ The workspace supports contact details, note search, Markdown creation/editing,
94
+ safe preview, history and draft recovery. Note edits append immutable user
95
+ revisions; original source content and attribution remain intact. No raw HTML
96
+ or remote images are rendered. Notes never enter cloud sync or agent packets.
97
+
98
+ ```bash
99
+ peopleblade notes show 42 --json
100
+ peopleblade notes update 42 --expected-revision 0 \
101
+ --expected-context CONTEXT_SHA256 --request-id REQUEST_UUID \
102
+ --body-file /private/path/note.md --json
103
+ peopleblade notes history 42 --json
104
+ ```
105
+
106
+ Read the revision and context digest from `notes show`. Use a fresh UUID for a
107
+ new edit; retry an uncertain edit with the same UUID and exact input. Stale
108
+ content or identity context fails without overwriting a newer edit. `--body-file -`
109
+ reads bounded UTF-8 stdin. `--title` changes the title, `--clear-title` clears it,
110
+ and omission retains the title from the expected revision.
111
+
112
+ The versioned `research prepare` envelope can be filled and passed directly to
113
+ `research apply`. Keep its subject and instructions unchanged; fill only `result`.
114
+ The envelope binds this database and its current identity evidence. The strict
115
+ legacy three-field input remains supported. JSON-mode errors are one structured
116
+ `peopleblade.error.v1` object on stderr; successful results stay on stdout.
67
117
 
68
118
  ## Privacy boundary
69
119
 
70
120
  PeopleBlade's provider import paths never retain, search, enrich from, or upload
71
121
  message text. The optional cloud projection excludes source records, raw archives,
72
- local paths, provider credentials, and message bodies. Ambiguous identity links
122
+ local paths, provider credentials, note bodies and revisions, and message bodies. Ambiguous identity links
73
123
  remain reviewable instead of being silently merged.
74
124
 
75
- This npm artifact contains only the bundled local CLI, its pinned Wrench client
125
+ This npm artifact contains only the bundled local CLI, its pinned Ghostget client
76
126
  validators and executable bridge, SQLite migrations, and package documentation.
77
- It does not contain the Wrench execution runtime, PeopleBlade web server, billing
127
+ It includes the self-contained loopback workspace, but not the Ghostget execution
128
+ runtime, hosted PeopleBlade web server, billing
78
129
  implementation, operational tooling, tests, or private product documentation.
79
130
 
80
131
  ## Update or remove
@@ -1,8 +1,8 @@
1
1
  # Third-party notices
2
2
 
3
3
  The PeopleBlade CLI bundle includes reviewed client-boundary code from
4
- `@hraness/wrench` 0.16.8 (commit
5
- `b0cf2ecbc1113fcc8ef673a824be475c3d7bbb46`) and message-bundle contract code
4
+ `@hraness/ghostget` 0.17.1 (commit
5
+ `b74ae2ac594bcf07c3def71dc4bb2e5a98c826dd`) and message-bundle contract code
6
6
  from `@hraness/message-like-me` 0.7.0. Those portions are provided under the
7
7
  following license. The PeopleBlade CLI package has its own MIT grant in
8
8
  `LICENSE`; this notice preserves the attribution for the bundled portions.
package/dist/cli.ts CHANGED
@@ -3,10 +3,20 @@
3
3
  import { accessSync, constants, realpathSync } from "node:fs";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
 
6
- const reviewedWrenchVersion = "0.16.8";
7
- const configured = process.env.PEOPLEBLADE_WRENCH_EXECUTABLE;
6
+ const reviewedGhostgetVersion = "0.17.1";
7
+ const canonicalConfigured = process.env.PEOPLEBLADE_GHOSTGET_EXECUTABLE;
8
+ const legacyConfigured = process.env.PEOPLEBLADE_WRENCH_EXECUTABLE;
9
+ if (
10
+ canonicalConfigured !== undefined
11
+ && legacyConfigured !== undefined
12
+ && canonicalConfigured !== legacyConfigured
13
+ ) {
14
+ console.error("PEOPLEBLADE_GHOSTGET_EXECUTABLE and PEOPLEBLADE_WRENCH_EXECUTABLE must name the same executable when both are set.");
15
+ process.exit(127);
16
+ }
17
+ const configured = canonicalConfigured ?? legacyConfigured;
8
18
  const executable = configured === undefined
9
- ? Bun.which("wrench")
19
+ ? Bun.which("ghostget")
10
20
  : isAbsolute(configured)
11
21
  ? resolve(configured)
12
22
  : null;
@@ -14,8 +24,8 @@ const executable = configured === undefined
14
24
  if (executable === null) {
15
25
  console.error(
16
26
  configured === undefined
17
- ? "PeopleBlade Wrench-backed sources require a separately reviewed `wrench` executable on PATH."
18
- : "PEOPLEBLADE_WRENCH_EXECUTABLE must be an absolute executable path.",
27
+ ? "PeopleBlade Ghostget-backed sources require a separately reviewed `ghostget` executable on PATH."
28
+ : "PEOPLEBLADE_GHOSTGET_EXECUTABLE must be an absolute executable path.",
19
29
  );
20
30
  process.exit(127);
21
31
  }
@@ -25,11 +35,11 @@ try {
25
35
  accessSync(executable, constants.X_OK);
26
36
  canonicalExecutable = realpathSync(executable);
27
37
  } catch {
28
- console.error("The configured PeopleBlade Wrench executable is unavailable or not executable.");
38
+ console.error("The configured PeopleBlade Ghostget executable is unavailable or not executable.");
29
39
  process.exit(127);
30
40
  }
31
41
  if (process.argv[1] !== undefined && canonicalExecutable === realpathSync(process.argv[1])) {
32
- console.error("The PeopleBlade Wrench executable bridge cannot delegate to itself.");
42
+ console.error("The PeopleBlade Ghostget executable bridge cannot delegate to itself.");
33
43
  process.exit(127);
34
44
  }
35
45
 
@@ -54,11 +64,11 @@ if (
54
64
  || versionProbe.signalCode !== undefined
55
65
  || versionProbe.exitedDueToTimeout === true
56
66
  || versionProbe.exitedDueToMaxBuffer === true
57
- || versionProbe.stdout?.toString() !== `${reviewedWrenchVersion}\n`
67
+ || versionProbe.stdout?.toString() !== `${reviewedGhostgetVersion}\n`
58
68
  || versionProbe.stderr?.byteLength !== 0
59
69
  ) {
60
70
  console.error(
61
- `PeopleBlade Wrench-backed sources require the exact reviewed Wrench ${reviewedWrenchVersion} executable.`,
71
+ `PeopleBlade Ghostget-backed sources require the exact reviewed Ghostget ${reviewedGhostgetVersion} executable.`,
62
72
  );
63
73
  process.exit(126);
64
74
  }
@@ -354,6 +354,7 @@ var serverCliEnrichmentStartSchema = z.object({
354
354
  var cliEnrichmentStatusSchema = z.object({
355
355
  jobId: z.uuid()
356
356
  }).strict();
357
+ var cliEnrichmentPublicEmailsRequestSchema = z.object({}).strict();
357
358
  var cliEnrichmentRevalidationSchema = z.object({
358
359
  limit: z.number().int().min(1).max(100)
359
360
  }).strict();
@@ -1460,7 +1461,14 @@ function localSchemaVersion(database) {
1460
1461
  throw new Error("PeopleBlade schema is not initialized.");
1461
1462
  return row.count;
1462
1463
  }
1463
- function projectCloudContacts(database) {
1464
+ function projectCloudContacts(database, options = {}) {
1465
+ const selected = options.personIds === undefined ? undefined : [...new Set(options.personIds)];
1466
+ if (selected !== undefined && (selected.length > 100 || selected.some((id) => !Number.isSafeInteger(id) || id < 1))) {
1467
+ throw new Error("Projection selection requires at most 100 positive canonical person IDs.");
1468
+ }
1469
+ if (selected?.length === 0)
1470
+ return [];
1471
+ const selectionSql = selected === undefined ? "" : `AND p.id IN (${selected.map(() => "?").join(",")})`;
1464
1472
  const rows = database.query(`
1465
1473
  WITH
1466
1474
  ${canonicalInteractionRollupCtesSql},
@@ -1627,11 +1635,12 @@ function projectCloudContacts(database) {
1627
1635
  LEFT JOIN source_rollup ON source_rollup.person_id = p.id
1628
1636
  LEFT JOIN handle_rollup ON handle_rollup.person_id = p.id
1629
1637
  LEFT JOIN resource_eligibility ON resource_eligibility.person_id = p.id
1630
- WHERE resource_eligibility.person_id IS NULL
1638
+ WHERE (resource_eligibility.person_id IS NULL
1631
1639
  OR resource_eligibility.has_active_resource = 1
1632
- OR canonical_interaction_rollup.person_id IS NOT NULL
1640
+ OR canonical_interaction_rollup.person_id IS NOT NULL)
1641
+ ${selectionSql}
1633
1642
  ORDER BY p.id
1634
- `).all();
1643
+ `).all(...selected ?? []);
1635
1644
  const instance = localDatabaseFingerprint(database);
1636
1645
  return rows.map((row) => {
1637
1646
  const sources = sourceLabelSchema.array().parse(parseStringArray(row.sources_json).filter((source) => sourceLabelSchema.safeParse(source).success));
@@ -0,0 +1,151 @@
1
+ PRAGMA foreign_keys = ON;
2
+
3
+ -- User edits are overlays; original provider content and attribution stay immutable.
4
+ CREATE TABLE person_note_content_revisions (
5
+ note_id INTEGER NOT NULL REFERENCES person_notes(id) ON DELETE RESTRICT,
6
+ revision INTEGER NOT NULL CHECK(revision BETWEEN 1 AND 9007199254740991),
7
+ expected_revision INTEGER NOT NULL CHECK(expected_revision BETWEEN 0 AND 9007199254740990),
8
+ request_id TEXT NOT NULL UNIQUE CHECK(length(request_id) = 36),
9
+ expected_context_sha256 TEXT NOT NULL CHECK(
10
+ length(expected_context_sha256) = 64
11
+ AND expected_context_sha256 NOT GLOB '*[^0-9a-f]*'
12
+ ),
13
+ context_json TEXT NOT NULL CHECK(json_valid(context_json)),
14
+ title TEXT CHECK(title IS NULL OR (length(title) BETWEEN 1 AND 1024 AND instr(title, char(0)) = 0)),
15
+ body TEXT NOT NULL CHECK(length(CAST(body AS BLOB)) BETWEEN 1 AND 65536 AND instr(body, char(0)) = 0),
16
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
17
+ PRIMARY KEY(note_id, revision),
18
+ CHECK(revision = expected_revision + 1)
19
+ ) STRICT;
20
+
21
+ CREATE TRIGGER person_note_content_revision_valid
22
+ BEFORE INSERT ON person_note_content_revisions FOR EACH ROW
23
+ BEGIN
24
+ SELECT CASE WHEN
25
+ EXISTS (SELECT 1 FROM person_note_content_revisions prior
26
+ WHERE prior.request_id = NEW.request_id
27
+ OR (prior.note_id = NEW.note_id AND prior.revision = NEW.revision))
28
+ OR NEW.expected_revision IS NOT coalesce(
29
+ (SELECT max(prior.revision) FROM person_note_content_revisions prior WHERE prior.note_id = NEW.note_id), 0)
30
+ OR NOT EXISTS (SELECT 1 FROM person_notes WHERE id = NEW.note_id)
31
+ OR json_extract(NEW.context_json, '$.schemaVersion') IS NOT 1
32
+ OR json_extract(NEW.context_json, '$.databaseInstanceId') IS NOT
33
+ (SELECT instance_id FROM local_state WHERE singleton = 1)
34
+ OR json_extract(NEW.context_json, '$.noteId') IS NOT NEW.note_id
35
+ OR json_extract(NEW.context_json, '$.revision') IS NOT NEW.expected_revision
36
+ OR json_extract(NEW.context_json, '$.originalPersonId') IS NOT
37
+ (SELECT person_id FROM person_notes WHERE id = NEW.note_id)
38
+ OR json_extract(NEW.context_json, '$.attributedPersonId') IS NOT
39
+ (SELECT person_id FROM person_notes_current WHERE id = NEW.note_id)
40
+ OR json_extract(NEW.context_json, '$.attributionRevisionId') IS NOT coalesce(
41
+ (SELECT max(id) FROM person_note_attribution_revisions WHERE note_id = NEW.note_id), 0)
42
+ OR json_extract(NEW.context_json, '$.identityRevision') IS NOT
43
+ (SELECT identity_revision FROM local_revisions WHERE singleton = 1)
44
+ OR json_extract(NEW.context_json, '$.canonicalPersonId') IS NOT
45
+ (SELECT component.canonical_person_id FROM person_identity_components component
46
+ JOIN person_notes_current note ON note.person_id = component.person_id WHERE note.id = NEW.note_id)
47
+ OR json_extract(NEW.context_json, '$.componentPersonIds') IS NOT (
48
+ SELECT json_group_array(person_id) FROM (
49
+ SELECT member.person_id FROM person_identity_components member
50
+ WHERE member.canonical_person_id = (
51
+ SELECT component.canonical_person_id FROM person_identity_components component
52
+ JOIN person_notes_current note ON note.person_id = component.person_id WHERE note.id = NEW.note_id
53
+ ) ORDER BY member.person_id
54
+ )
55
+ )
56
+ THEN RAISE(ABORT, 'person note revision context is not current') END;
57
+ END;
58
+
59
+ CREATE TABLE person_note_creation_requests (
60
+ request_id TEXT PRIMARY KEY CHECK(length(request_id) = 36),
61
+ note_id INTEGER NOT NULL UNIQUE REFERENCES person_notes(id) ON DELETE RESTRICT,
62
+ requested_person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE RESTRICT,
63
+ expected_context_sha256 TEXT NOT NULL CHECK(
64
+ length(expected_context_sha256) = 64 AND expected_context_sha256 NOT GLOB '*[^0-9a-f]*'
65
+ ),
66
+ context_json TEXT NOT NULL CHECK(json_valid(context_json)),
67
+ occurred_at_input TEXT NOT NULL CHECK(length(occurred_at_input) BETWEEN 1 AND 80)
68
+ ) STRICT;
69
+
70
+ CREATE TRIGGER person_note_creation_request_valid
71
+ BEFORE INSERT ON person_note_creation_requests FOR EACH ROW
72
+ BEGIN
73
+ SELECT CASE WHEN
74
+ EXISTS (SELECT 1 FROM person_note_creation_requests prior
75
+ WHERE prior.request_id = NEW.request_id OR prior.note_id = NEW.note_id)
76
+ OR json_extract(NEW.context_json, '$.schemaVersion') IS NOT 1
77
+ OR json_extract(NEW.context_json, '$.databaseInstanceId') IS NOT
78
+ (SELECT instance_id FROM local_state WHERE singleton = 1)
79
+ OR json_extract(NEW.context_json, '$.personId') IS NOT NEW.requested_person_id
80
+ OR json_extract(NEW.context_json, '$.identityRevision') IS NOT
81
+ (SELECT identity_revision FROM local_revisions WHERE singleton = 1)
82
+ OR json_extract(NEW.context_json, '$.canonicalPersonId') IS NOT
83
+ (SELECT canonical_person_id FROM person_identity_components WHERE person_id = NEW.requested_person_id)
84
+ OR json_extract(NEW.context_json, '$.componentPersonIds') IS NOT (
85
+ SELECT json_group_array(person_id) FROM (
86
+ SELECT member.person_id FROM person_identity_components member
87
+ WHERE member.canonical_person_id = (
88
+ SELECT canonical_person_id FROM person_identity_components WHERE person_id = NEW.requested_person_id
89
+ ) ORDER BY member.person_id
90
+ )
91
+ )
92
+ OR NOT EXISTS (SELECT 1 FROM person_notes note
93
+ WHERE note.id = NEW.note_id AND note.source = 'manual' AND note.source_id = NEW.request_id
94
+ AND note.person_id = json_extract(NEW.context_json, '$.canonicalPersonId'))
95
+ THEN RAISE(ABORT, 'person note creation context is not current') END;
96
+ END;
97
+
98
+ CREATE TRIGGER person_note_creation_requests_immutable_update
99
+ BEFORE UPDATE ON person_note_creation_requests FOR EACH ROW
100
+ BEGIN SELECT RAISE(ABORT, 'person note creation requests are immutable'); END;
101
+ CREATE TRIGGER person_note_creation_requests_immutable_delete
102
+ BEFORE DELETE ON person_note_creation_requests FOR EACH ROW
103
+ BEGIN SELECT RAISE(ABORT, 'person note creation requests are immutable'); END;
104
+
105
+ CREATE TRIGGER person_note_content_revisions_immutable_update
106
+ BEFORE UPDATE ON person_note_content_revisions FOR EACH ROW
107
+ BEGIN SELECT RAISE(ABORT, 'person note content revisions are immutable'); END;
108
+ CREATE TRIGGER person_note_content_revisions_immutable_delete
109
+ BEFORE DELETE ON person_note_content_revisions FOR EACH ROW
110
+ BEGIN SELECT RAISE(ABORT, 'person note content revisions are immutable'); END;
111
+
112
+ CREATE VIEW person_notes_effective AS
113
+ SELECT note.id, note.person_id, note.occurred_at, note.source, note.source_id,
114
+ CASE WHEN revision.revision IS NULL THEN note.title ELSE revision.title END AS title,
115
+ coalesce(revision.body, note.body) AS body,
116
+ note.metadata_json, note.created_at,
117
+ coalesce(revision.revision, 0) AS revision,
118
+ revision.created_at AS revised_at
119
+ FROM person_notes_current note
120
+ LEFT JOIN person_note_content_revisions revision ON revision.note_id = note.id
121
+ AND revision.revision = (
122
+ SELECT max(latest.revision) FROM person_note_content_revisions latest WHERE latest.note_id = note.id
123
+ );
124
+
125
+ CREATE VIRTUAL TABLE person_notes_effective_fts USING fts5(
126
+ title, body, content='person_notes_effective', content_rowid='id',
127
+ tokenize='unicode61 remove_diacritics 2'
128
+ );
129
+ INSERT INTO person_notes_effective_fts(rowid, title, body)
130
+ SELECT id, coalesce(title, ''), body FROM person_notes_effective;
131
+
132
+ CREATE TRIGGER person_notes_effective_fts_original_insert
133
+ AFTER INSERT ON person_notes FOR EACH ROW
134
+ BEGIN
135
+ INSERT INTO person_notes_effective_fts(rowid, title, body)
136
+ VALUES (NEW.id, coalesce(NEW.title, ''), NEW.body);
137
+ END;
138
+
139
+ CREATE TRIGGER person_notes_effective_fts_revision_insert
140
+ AFTER INSERT ON person_note_content_revisions FOR EACH ROW
141
+ BEGIN
142
+ INSERT INTO person_notes_effective_fts(person_notes_effective_fts, rowid, title, body)
143
+ SELECT 'delete', NEW.note_id, coalesce(note.title, ''), note.body
144
+ FROM person_notes note WHERE note.id = NEW.note_id AND NEW.expected_revision = 0;
145
+ INSERT INTO person_notes_effective_fts(person_notes_effective_fts, rowid, title, body)
146
+ SELECT 'delete', NEW.note_id, coalesce(prior.title, ''), prior.body
147
+ FROM person_note_content_revisions prior
148
+ WHERE prior.note_id = NEW.note_id AND prior.revision = NEW.expected_revision;
149
+ INSERT INTO person_notes_effective_fts(rowid, title, body)
150
+ VALUES (NEW.note_id, coalesce(NEW.title, ''), NEW.body);
151
+ END;