@hraness/peopleblade 0.3.3 → 0.3.5

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.3.3
18
+ bun add --global @hraness/peopleblade@0.3.5
19
19
  peopleblade --version
20
20
  peopleblade init
21
21
  peopleblade stats --json
@@ -51,7 +51,7 @@ execution runtime.
51
51
  path. When both variables are set, they must name the same executable. The
52
52
  bridge still requires exact Ghostget 0.17.3 before invoking a provider operation.
53
53
 
54
- PeopleBlade 0.3.3 contains the reviewed Ghostget-backed Beeper, Google, and WhatsApp
54
+ PeopleBlade 0.3.5 contains the reviewed Ghostget-backed Beeper, Google, and WhatsApp
55
55
  live sync commands. Running them requires the separately installed public
56
56
  `@hraness/ghostget` 0.17.3 package. Beeper support is bounded to account-aware
57
57
  `contacts.list@3` reads, `contacts.search@1`, `messaging.search@2`, and the separate
@@ -59,7 +59,9 @@ body-free interaction exporter. `contacts.search@1` executes the official Beeper
59
59
  CLI 0.6.2 executable pinned by Ghostget; `contacts.list@3` and `messaging.search@2`
60
60
  use Ghostget's bounded Desktop-loopback read paths. The exporter is macOS arm64 only
61
61
  and reports lower-bound interaction counts from complete one-to-one conversations
62
- only. See
62
+ only. If a history participant is absent from the current contact page, PeopleBlade
63
+ preserves its exact account-scoped coordinate as a relationship-only resource; it
64
+ does not join that resource by name or another fuzzy field. See
63
65
  [peopleblade.com/sources](https://peopleblade.com/sources) for the complete provider
64
66
  guide. A database previously synced by PeopleBlade 0.1.1 through Wrench 0.16.7
65
67
  must run `peopleblade beeper rebind --auth beeper-main --confirm --json` once after
@@ -3,8 +3,8 @@
3
3
  var __require = import.meta.require;
4
4
 
5
5
  // src/cli/main.ts
6
- import { closeSync as closeSync9, constants as constants9, existsSync as existsSync5, fstatSync as fstatSync8, openSync as openSync9, readSync as readSync3 } from "fs";
7
- import { resolve as resolve6 } from "path";
6
+ import { closeSync as closeSync9, constants as constants9, existsSync as existsSync6, fstatSync as fstatSync8, mkdirSync as mkdirSync3, openSync as openSync9, readSync as readSync3 } from "fs";
7
+ import { join as join7, resolve as resolve7 } from "path";
8
8
  import { ZodError } from "zod";
9
9
 
10
10
  // src/local/contacts.ts
@@ -6424,7 +6424,7 @@ function parseGranolaImportJson(raw) {
6424
6424
  var paper_theme_default = `/* @hraness/design-kit paper-theme.css, portable contract 1.
6425
6425
  * Generated by scripts/generate-paper-theme.ts from src/palettes.ts.
6426
6426
  * Opt in with data-hraness-theme="paper". Import after existing theme rules.
6427
- * No imports, asset requests, resets, component recipes, or runtime dependencies.
6427
+ * No imports, asset requests, resets, layout recipes, or runtime dependencies.
6428
6428
  * Appearance uses inherited color-scheme; set data-theme on the boundary for
6429
6429
  * an explicit island. A root without a mode follows the operating system.
6430
6430
  * A non-paper data-palette on the boundary retains its selected colors.
@@ -6487,6 +6487,41 @@ var paper_theme_default = `/* @hraness/design-kit paper-theme.css, portable cont
6487
6487
  [data-hraness-theme="paper"]:where([data-theme="dark"], .dark:not([data-theme="light"])) { color-scheme: dark; }
6488
6488
  [data-hraness-theme="paper"][data-theme="light"] { color-scheme: light; }
6489
6489
 
6490
+ /* Opted-in shared headers retain their layout and receive the shared paint
6491
+ * contract, including snapshots used with older component releases. */
6492
+ @layer components.hraness-design-kit.legacy {
6493
+ /* Keep the browser aliases in separate feature queries: CSS optimizers can
6494
+ * collapse adjacent aliases to the prefixed property and lose Chromium blur. */
6495
+ :where([data-hraness-theme="paper"]) .hraness-marketing-header, .hraness-marketing-header:where([data-hraness-theme="paper"]) {
6496
+ --hraness-marketing-header-background: var(--hraness-marketing-background);
6497
+ --hraness-marketing-header-backdrop: none;
6498
+ background: var(--hraness-marketing-header-background);
6499
+ }
6500
+
6501
+ @supports (-webkit-backdrop-filter: blur(1px)) {
6502
+ :where([data-hraness-theme="paper"]) .hraness-marketing-header, .hraness-marketing-header:where([data-hraness-theme="paper"]) {
6503
+ --hraness-marketing-header-background: color-mix(in oklch, var(--hraness-marketing-background) 82%, transparent);
6504
+ --hraness-marketing-header-backdrop: blur(14px) saturate(1.4);
6505
+ -webkit-backdrop-filter: var(--hraness-marketing-header-backdrop);
6506
+ }
6507
+ }
6508
+
6509
+ @supports (backdrop-filter: blur(1px)) {
6510
+ :where([data-hraness-theme="paper"]) .hraness-marketing-header, .hraness-marketing-header:where([data-hraness-theme="paper"]) {
6511
+ --hraness-marketing-header-background: color-mix(in oklch, var(--hraness-marketing-background) 82%, transparent);
6512
+ --hraness-marketing-header-backdrop: blur(14px) saturate(1.4);
6513
+ backdrop-filter: var(--hraness-marketing-header-backdrop);
6514
+ }
6515
+ }
6516
+
6517
+ @media (prefers-reduced-transparency: reduce), (forced-colors: active) {
6518
+ :where([data-hraness-theme="paper"]) .hraness-marketing-header, .hraness-marketing-header:where([data-hraness-theme="paper"]) {
6519
+ --hraness-marketing-header-background: var(--hraness-marketing-background);
6520
+ --hraness-marketing-header-backdrop: none;
6521
+ }
6522
+ }
6523
+ }
6524
+
6490
6525
  :root[data-hraness-theme="paper"]:where(:not([data-palette]), [data-palette="paper"]),
6491
6526
  [data-hraness-theme="paper"]:where(:not([data-palette]), [data-palette="paper"]) {
6492
6527
  --background: light-dark(#f8f7f4, #12100f);
@@ -6605,6 +6640,7 @@ var paper_theme_default = `/* @hraness/design-kit paper-theme.css, portable cont
6605
6640
  --control-border: ButtonText;
6606
6641
  --primary: Highlight;
6607
6642
  --primary-foreground: HighlightText;
6643
+ --plain-link: LinkText;
6608
6644
  --accent: Highlight;
6609
6645
  --accent-foreground: HighlightText;
6610
6646
  --focus: Highlight;
@@ -6619,19 +6655,228 @@ var paper_theme_default = `/* @hraness/design-kit paper-theme.css, portable cont
6619
6655
  }
6620
6656
  `;
6621
6657
 
6658
+ // styles/vendor/hraness-lantern/lantern-material.css
6659
+ var lantern_material_default = `/* Lantern is an opt-in material, independent of palette and typography.
6660
+ * All paint is deterministic CSS. No texture is placed over meaningful content. */
6661
+ @layer base {
6662
+ [data-hraness-material="lantern"] {
6663
+ --hraness-material-module: 6rem;
6664
+ --hraness-material-warm: #d49a54;
6665
+ --hraness-material-cool: #8d9fc5;
6666
+ --hraness-material-plane: var(--surface, var(--ui-background, Canvas));
6667
+ --hraness-material-ink: var(--foreground, var(--ui-foreground, CanvasText));
6668
+ --hraness-material-muted: var(--muted, var(--ui-muted-foreground, CanvasText));
6669
+ --hraness-material-seam: color-mix(in oklch, var(--hraness-material-ink) 7%, var(--hraness-material-plane));
6670
+ --hraness-material-edge: color-mix(in oklch, white 18%, transparent);
6671
+ --hraness-material-shade: color-mix(in oklch, black 14%, transparent);
6672
+ --hraness-material-warm-plane: color-mix(in oklch, var(--hraness-material-warm) 12%, var(--hraness-material-plane));
6673
+ --hraness-material-inset: inset 2px 3px 6px color-mix(in oklch, black 9%, transparent), inset -1px -1px 2px color-mix(in oklch, white 12%, transparent);
6674
+ --hraness-material-lift: inset 0 1px 0 var(--hraness-material-edge), -3px -3px 10px color-mix(in oklch, white 4%, transparent), 2px 4px 8px color-mix(in oklch, black 7%, transparent), 8px 18px 40px color-mix(in oklch, black 9%, transparent);
6675
+ --hraness-material-focus: var(--focus, var(--ui-ring, Highlight));
6676
+ --hraness-material-duration: 160ms;
6677
+ }
6678
+ }
6679
+
6680
+ @layer components.hraness-design-kit.legacy {
6681
+ /* A quiet, opaque reading plane. The seam belongs to the surface perimeter. */
6682
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-pane) {
6683
+ background-color: var(--hraness-material-plane);
6684
+ border: 1px solid var(--hraness-material-seam);
6685
+ border-radius: var(--radius-lg, 0.75rem);
6686
+ color: var(--hraness-material-ink);
6687
+ min-inline-size: 0;
6688
+ }
6689
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-pane[data-depth="raised"]) {
6690
+ box-shadow: var(--hraness-material-lift);
6691
+ }
6692
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-pane[data-depth="inset"]) {
6693
+ background-color: color-mix(in oklch, var(--background, Canvas) 70%, var(--hraness-material-plane));
6694
+ box-shadow: var(--hraness-material-inset);
6695
+ }
6696
+
6697
+ /* A translucent enclosure, only where content actually passes behind it.
6698
+ * Resolve these tokens on the chrome itself for nested palette islands. */
6699
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-chrome) {
6700
+ --hraness-material-chrome-paint: var(--surface, var(--ui-background, Canvas));
6701
+ --hraness-material-chrome-blur: none;
6702
+ background-color: var(--hraness-material-chrome-paint);
6703
+ border-block-end: 1px solid var(--hraness-material-seam);
6704
+ box-shadow: inset 0 1px 0 var(--hraness-material-edge), 0 4px 12px color-mix(in oklch, black 4%, transparent);
6705
+ }
6706
+ /* Bind the compiled TopBar's public paint seam at its own surface boundary. */
6707
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-chrome.hraness-design-top-bar[data-surface="glass"]) {
6708
+ --hraness-design-top-bar-background: var(--hraness-material-chrome-paint);
6709
+ --hraness-design-top-bar-backdrop: var(--hraness-material-chrome-blur);
6710
+ }
6711
+ @supports (-webkit-backdrop-filter: blur(1px)) {
6712
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-chrome) {
6713
+ --hraness-material-chrome-paint: color-mix(in oklch, var(--surface, var(--ui-background, Canvas)) 90%, transparent);
6714
+ --hraness-material-chrome-blur: blur(20px) saturate(1.1);
6715
+ -webkit-backdrop-filter: var(--hraness-material-chrome-blur);
6716
+ }
6717
+ }
6718
+ @supports (backdrop-filter: blur(1px)) {
6719
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-chrome) {
6720
+ --hraness-material-chrome-paint: color-mix(in oklch, var(--surface, var(--ui-background, Canvas)) 90%, transparent);
6721
+ --hraness-material-chrome-blur: blur(20px) saturate(1.1);
6722
+ backdrop-filter: var(--hraness-material-chrome-blur);
6723
+ }
6724
+ }
6725
+
6726
+ /* Glazing uses shaded faces rather than drawn seams, with a warm band
6727
+ * of transmitted light. Its edges suggest a rounded enclosure without
6728
+ * distorting the document, blurring text, or masking brand artwork. */
6729
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-wall) {
6730
+ background-color: var(--background, var(--ui-background, Canvas));
6731
+ background-image:
6732
+ var(--hraness-material-wall-grain, none),
6733
+ var(--hraness-material-wall-cells, linear-gradient(145deg, color-mix(in oklch, white 5%, transparent), transparent 48%, color-mix(in oklch, black 7%, transparent))),
6734
+ radial-gradient(ellipse at 60% 110%, color-mix(in oklch, var(--hraness-material-warm) 22%, transparent), transparent 68%),
6735
+ linear-gradient(110deg, color-mix(in oklch, var(--hraness-material-cool) 14%, transparent), transparent 38%, color-mix(in oklch, white 4%, transparent) 65%, transparent);
6736
+ background-size: 64px 64px, var(--hraness-material-wall-cell-size, var(--hraness-material-module)) var(--hraness-material-wall-cell-size, var(--hraness-material-module)), 100% 100%, 100% 100%;
6737
+ background-position: 0 0, center top, center top, center top;
6738
+ color: var(--hraness-material-ink);
6739
+ }
6740
+
6741
+ /* A terminal remains a readable document. Source lines scroll intact;
6742
+ * only its enclosure catches light. Syntax spans inherit semantic colors. */
6743
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-terminal) {
6744
+ min-inline-size: 0;
6745
+ overflow: hidden;
6746
+ border: 1px solid var(--hraness-material-seam);
6747
+ border-radius: .875rem;
6748
+ background: var(--hraness-material-plane);
6749
+ color: var(--hraness-material-ink);
6750
+ box-shadow: var(--hraness-material-lift);
6751
+ }
6752
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-terminal__bar) {
6753
+ padding: .8rem 1.25rem;
6754
+ background: linear-gradient(to bottom, color-mix(in oklch, white 5%, var(--hraness-material-plane)), var(--hraness-material-plane));
6755
+ box-shadow: inset 0 1px 0 var(--hraness-material-edge), 0 1px 3px color-mix(in oklch, black 8%, transparent);
6756
+ color: var(--hraness-material-muted);
6757
+ font: .8125rem/1.5 var(--font-mono, ui-monospace, monospace);
6758
+ }
6759
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-code) {
6760
+ min-inline-size: 0;
6761
+ max-inline-size: 100%;
6762
+ margin: 0;
6763
+ padding: 1.25rem;
6764
+ overflow: auto;
6765
+ background: var(--hraness-material-plane);
6766
+ color: var(--hraness-material-ink);
6767
+ font: .875rem/1.75 var(--font-mono, ui-monospace, monospace);
6768
+ white-space: pre;
6769
+ overflow-wrap: normal;
6770
+ word-break: normal;
6771
+ tab-size: 2;
6772
+ }
6773
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-code) code {
6774
+ background: none;
6775
+ padding: 0;
6776
+ font: inherit;
6777
+ white-space: inherit;
6778
+ overflow-wrap: inherit;
6779
+ word-break: inherit;
6780
+ }
6781
+
6782
+ /* Plain HTML control adapters. Compiled UI primitives use the public
6783
+ * lanternControlStyles recipes through their documented controlXstyle seam.
6784
+ * Edge paint uses background images, leaving focus shadows and outlines alone. */
6785
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-control) {
6786
+ background-image: linear-gradient(to bottom, var(--hraness-material-edge), transparent 2px);
6787
+ }
6788
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-input) {
6789
+ background-image: linear-gradient(to bottom, var(--hraness-material-shade), transparent 4px);
6790
+ caret-color: var(--hraness-material-focus);
6791
+ }
6792
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-choice[aria-pressed="true"], .hraness-material-choice[aria-selected="true"], .hraness-material-choice[data-selected]) {
6793
+ background-color: var(--hraness-material-warm-plane);
6794
+ color: var(--hraness-material-ink);
6795
+ background-image: linear-gradient(to top, var(--hraness-material-warm), var(--hraness-material-warm) 2px, transparent 2px);
6796
+ }
6797
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-control:disabled, .hraness-material-control[aria-disabled="true"], .hraness-material-control[data-disabled], .hraness-material-input:disabled) {
6798
+ background-image: none;
6799
+ }
6800
+
6801
+ /* A collection is a readable sequence, never a requirement to fill a grid.
6802
+ * Products own grouping, columns and the meaning of each row. */
6803
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-rows) {
6804
+ margin: 0;
6805
+ padding: 0;
6806
+ list-style: none;
6807
+ }
6808
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-rows > * + *) {
6809
+ border-block-start: 1px solid var(--hraness-material-seam);
6810
+ }
6811
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-disclosure > summary) {
6812
+ cursor: pointer;
6813
+ min-block-size: 2.75rem;
6814
+ padding-block: 0.75rem;
6815
+ }
6816
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-disclosure > summary:focus-visible) {
6817
+ outline: 2px solid var(--hraness-material-focus);
6818
+ outline-offset: 3px;
6819
+ }
6820
+ [data-hraness-material="lantern"] ::selection {
6821
+ background-color: var(--hraness-material-warm-plane);
6822
+ color: var(--hraness-material-ink);
6823
+ }
6824
+
6825
+ @media (prefers-reduced-motion: reduce) {
6826
+ [data-hraness-material="lantern"] { --hraness-material-duration: 0ms; }
6827
+ }
6828
+ @media (prefers-reduced-transparency: reduce) {
6829
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-chrome) {
6830
+ --hraness-material-chrome-paint: var(--surface, var(--ui-background, Canvas));
6831
+ --hraness-material-chrome-blur: none;
6832
+ }
6833
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-wall) { background-image: none; }
6834
+ }
6835
+ @media (forced-colors: active) {
6836
+ [data-hraness-material="lantern"] {
6837
+ --hraness-material-plane: Canvas;
6838
+ --hraness-material-ink: CanvasText;
6839
+ --hraness-material-muted: CanvasText;
6840
+ --hraness-material-seam: CanvasText;
6841
+ --hraness-material-focus: Highlight;
6842
+ --hraness-material-warm-plane: Canvas;
6843
+ --hraness-material-warm: Highlight;
6844
+ --hraness-material-edge: transparent;
6845
+ --hraness-material-inset: none;
6846
+ --hraness-material-lift: none;
6847
+ }
6848
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-wall, .hraness-material-chrome, .hraness-material-pane, .hraness-material-terminal, .hraness-material-terminal__bar, .hraness-material-code) {
6849
+ --hraness-material-chrome-paint: Canvas;
6850
+ --hraness-material-chrome-blur: none;
6851
+ background-color: Canvas;
6852
+ background-image: none;
6853
+ box-shadow: none;
6854
+ }
6855
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-control, .hraness-material-input, .hraness-material-choice) { background-image: none; }
6856
+ :where([data-hraness-material="lantern"], [data-hraness-material="lantern"] *):is(.hraness-material-choice[aria-pressed="true"], .hraness-material-choice[aria-selected="true"], .hraness-material-choice[data-selected]) {
6857
+ background-color: Highlight;
6858
+ color: HighlightText;
6859
+ background-image: none;
6860
+ }
6861
+ }
6862
+ }
6863
+ `;
6864
+
6622
6865
  // src/local/workspace-assets.ts
6623
6866
  var workspaceHtml = `<!doctype html>
6624
- <html lang="en" data-hraness-theme="paper"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
6867
+ <html lang="en" data-hraness-theme="paper" data-hraness-material="lantern"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
6625
6868
  <title>PeopleBlade \xB7 Local workspace</title><link rel="stylesheet" href="/workspace.css"><script src="/workspace.js" defer></script></head>
6626
- <body><a class="skip" href="#workspace">Skip to contacts</a><header><a class="brand" href="/">PeopleBlade<span>LOCAL WORKSPACE</span></a><p>Your people. Your machine.</p><span class="privacy">No cloud sync \xB7 no enrichment calls</span></header>
6627
- <main id="workspace"><section id="unlock" class="unlock"><span class="eyebrow">PRIVATE BY DEFAULT</span><h1>Open your local contact book.</h1><p>Select the access file printed by <code>peopleblade ui</code>. It connects this tab to that running process only. The file is not uploaded to a remote service.</p><label class="file-label">Workspace access file<input id="access-file" type="file" accept="application/json,.json"></label><p class="muted">Keep the terminal running. Closing it locks the workspace. Notes never leave this machine.</p></section>
6869
+ <body><a class="skip" href="#workspace">Skip to contacts</a><header class="workspace-header hraness-material-chrome"><a class="brand" href="/">PeopleBlade<span>LOCAL WORKSPACE</span></a><p>Your people. Your machine.</p><span class="privacy">No cloud sync \xB7 no enrichment calls</span></header>
6870
+ <main id="workspace"><section id="unlock" class="unlock hraness-material-pane"><span class="eyebrow">PRIVATE BY DEFAULT</span><h1>Open your local contact book.</h1><p>Select the access file printed by <code>peopleblade ui</code>. It connects this tab to that running process only. The file is not uploaded to a remote service.</p><label class="file-label">Workspace access file<input id="access-file" type="file" accept="application/json,.json"></label><p class="muted">Keep the terminal running. Closing it locks the workspace. Notes never leave this machine.</p></section>
6628
6871
  <section id="book" hidden><div class="page-heading"><div><span class="eyebrow">YOUR RELATIONSHIPS</span><h1>Contact book</h1></div><button id="lock" type="button">Lock workspace</button></div>
6629
6872
  <form id="filters" class="filters"><label class="search">Search contacts<input id="search" name="search" type="search" placeholder="Name, company, title, email, phone\u2026" maxlength="200"></label><label>Source<select id="source"><option value="">All sources</option><option>apple</option><option>beeper</option><option>google</option><option>imessage</option><option>linkedin</option><option>instagram</option><option>whatsapp</option><option>x</option></select></label><label>Email<select id="email"><option value="">Any email</option><option value="yes">Has email</option><option value="no">Needs email</option></select></label><label>Contact preference<select id="dnc"><option value="all">All contacts</option><option value="exclude">May contact</option><option value="only">Do not contact</option></select></label><button type="submit">Search</button><button id="reset" type="button">Reset</button></form>
6630
- <div class="workspace-grid"><section class="records" aria-label="Contacts"><div class="table-toolbar"><span id="range" aria-live="polite"></span><div><label>Sort <select id="sort"><option value="interactions">Interactions</option><option value="name">Name</option><option value="organization">Company</option><option value="last-contact">Last interaction</option></select></label><button id="direction" type="button" aria-label="Toggle sort direction">Descending \u2193</button></div></div><div class="table-scroll" tabindex="0" aria-label="Scrollable contacts"><table><caption class="sr-only">Local contacts; open a person to inspect details and notes.</caption><thead><tr><th scope="col">Person</th><th scope="col">Company</th><th scope="col">Interactions</th></tr></thead><tbody id="rows"></tbody></table></div><div id="empty" class="empty" hidden>No matches. Try another search or import a source with the CLI.</div><nav class="pagination" aria-label="Contact pages"><button id="previous" type="button">\u2190 Previous</button><span id="page"></span><button id="next" type="button">Next \u2192</button></nav><p class="live-note">Live view: imports and identity reviews can move rows between pages. Refresh after changes.</p></section>
6631
- <aside id="detail" class="detail" aria-label="Contact details"><div class="empty"><h2>A little context goes a long way.</h2><p>Open a person to see their contact methods, sources and private notes.</p></div></aside></div></section>
6873
+ <div class="workspace-grid"><section class="records hraness-material-pane" aria-label="Contacts"><div class="table-toolbar"><span id="range" aria-live="polite"></span><div><label>Sort <select id="sort"><option value="interactions">Interactions</option><option value="name">Name</option><option value="organization">Company</option><option value="last-contact">Last interaction</option></select></label><button id="direction" type="button" aria-label="Toggle sort direction">Descending \u2193</button></div></div><div class="table-scroll" tabindex="0" aria-label="Scrollable contacts"><table><caption class="sr-only">Local contacts; open a person to inspect details and notes.</caption><thead><tr><th scope="col">Person</th><th scope="col">Company</th><th scope="col">Interactions</th></tr></thead><tbody id="rows"></tbody></table></div><div id="empty" class="empty" hidden>No matches. Try another search or import a source with the CLI.</div><nav class="pagination" aria-label="Contact pages"><button id="previous" type="button">\u2190 Previous</button><span id="page"></span><button id="next" type="button">Next \u2192</button></nav><p class="live-note">Live view: imports and identity reviews can move rows between pages. Refresh after changes.</p></section>
6874
+ <aside id="detail" class="detail hraness-material-pane" aria-label="Contact details"><div class="empty"><h2>A little context goes a long way.</h2><p>Open a person to see their contact methods, sources and private notes.</p></div></aside></div></section>
6632
6875
  <p id="status" role="status" aria-live="polite"></p></main><footer>PeopleBlade \xB7 Local authority, reviewable identity, private notes.</footer></body></html>`;
6633
6876
  var workspaceCss = paper_theme_default + `
6634
- :root{--bg:var(--background);--panel:var(--surface);--text:var(--foreground);--soft:var(--secondary)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 var(--font-text)}button,input,select,textarea{font:inherit;color:inherit}button,input,select,textarea{border:1px solid var(--line);border-radius:7px;background:var(--panel);padding:9px 12px}button{cursor:pointer;font-weight:550}button:hover{border-color:var(--primary);background:var(--soft)}button:disabled{opacity:.45;cursor:default}a{color:var(--primary)}:focus-visible{outline:3px solid var(--primary);outline-offset:3px}header{display:flex;align-items:center;gap:30px;padding:22px 32px;border-bottom:1px solid var(--line);background:var(--panel)}header p{color:var(--muted);margin:0}.brand{color:var(--text);font-size:22px;line-height:1.1;text-decoration:none;font-weight:650;letter-spacing:-.7px}.brand span{display:block;font-size:9px;letter-spacing:1.8px;color:var(--muted);margin-top:8px}.privacy{margin-left:auto;border:1px solid var(--line);border-radius:99px;padding:6px 12px;font-size:11px;color:var(--muted)}main{max-width:1600px;margin:auto;padding:36px 32px}h1{font-size:34px;line-height:1.2;letter-spacing:-1px;font-weight:550;margin:8px 0 24px}h2{font-size:21px;line-height:1.3;letter-spacing:-.5px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.unlock{max-width:600px;margin:7vh auto;padding:36px;background:var(--panel);border:1px solid var(--line);border-radius:12px}.unlock p{color:var(--muted)}.file-label{display:grid;gap:8px;margin:25px 0}.page-heading{display:flex;align-items:center;justify-content:space-between}.page-heading button{margin-bottom:20px}.filters{display:flex;gap:10px;align-items:end;margin-bottom:20px;flex-wrap:wrap}.filters label{display:grid;gap:5px;font-size:11px;color:var(--muted)}.filters input,.filters select{font-size:13px;color:var(--text);min-height:42px}.filters .search{flex:1;min-width:220px}.workspace-grid{display:grid;grid-template-columns:minmax(420px,1.4fr) minmax(320px,1fr);gap:20px;align-items:start}.records,.detail{border:1px solid var(--line);background:var(--panel);border-radius:10px;overflow:hidden}.table-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px;flex-wrap:wrap}.table-toolbar>div{display:flex;align-items:center;gap:6px}.table-toolbar select,.table-toolbar button{padding:5px 8px;font-size:12px}.table-scroll{overflow:auto;max-height:65vh}table{width:100%;border-collapse:collapse;text-align:left}th{position:sticky;top:0;background:var(--soft);font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);z-index:1}th,td{padding:11px 16px;border-bottom:1px solid var(--line);vertical-align:top}td{font-size:12px}td small{display:block;color:var(--muted);margin-top:2px}td button{padding:0;border:0;background:none;text-align:left;font-size:13px;color:var(--text)}tr.selected{background:var(--soft)}tr:hover{background:var(--soft)}.pagination{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;font-size:12px}.pagination button{font-size:12px;padding:6px 10px}.live-note{font-size:10px;color:var(--muted);padding:0 16px 12px;margin:0}.detail{padding:24px;position:sticky;top:20px;max-height:83vh;overflow:auto}.detail h2{font-size:25px}.detail dl{display:grid;grid-template-columns:90px 1fr;gap:8px;margin:22px 0}.detail dt{font-size:11px;color:var(--muted)}.detail dd{margin:0;font-size:12px;overflow-wrap:anywhere}.detail dd span{display:block}.pill{display:inline-block;border:1px solid var(--line);border-radius:99px;padding:2px 8px;margin:0 4px 5px 0;color:var(--muted);font-size:10px}.notes-heading{border-top:1px solid var(--line);padding-top:22px;margin-top:24px;display:flex;justify-content:space-between;align-items:center}.notes-heading h3{margin:0;font-weight:550}.note-card{border-bottom:1px solid var(--line);padding:16px 0}.note-card>button{font-size:12px;padding:5px 9px;margin:8px 6px 0 0}.note-meta{font-size:10px;color:var(--muted);margin:4px 0 10px}.note-title{font-size:14px;font-weight:600;margin:0}.markdown{font-size:13px;overflow-wrap:anywhere}.markdown h1,.markdown h2,.markdown h3{font-size:17px;margin:12px 0}.markdown pre{overflow:auto;background:var(--soft);padding:12px;border-radius:6px}.markdown img{display:none}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}.markdown p{margin:8px 0}.markdown td,.markdown th{padding:5px;position:static}.editor{display:grid;gap:10px}.editor label{display:grid;gap:4px;font-size:11px;color:var(--muted)}.editor textarea{min-height:230px;resize:vertical;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;tab-size:2}.editor-actions{display:flex;gap:6px;flex-wrap:wrap}.editor-actions button{font-size:12px}.primary{background:var(--primary);color:var(--primary-foreground)}.primary:hover{background:var(--primary);color:var(--primary-foreground)}.empty{padding:44px 22px;color:var(--muted);text-align:center}.empty h2{font-size:21px}.muted{color:var(--muted);font-size:12px}.danger{color:var(--danger)}#status{background:var(--panel);border:1px solid var(--line);padding:12px 16px;border-radius:8px}#status:empty{display:none}footer{color:var(--muted);font-size:11px;text-align:center;padding:20px}.skip{position:absolute;left:12px;top:-100px;padding:8px;background:var(--panel);z-index:5}.skip:focus{top:8px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
6877
+ ` + lantern_material_default + `
6878
+ :root{--bg:var(--background);--panel:var(--surface);--text:var(--foreground);--soft:var(--secondary)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 var(--font-text)}button,input,select,textarea{font:inherit;color:inherit}button,input,select,textarea{border:1px solid var(--line);border-radius:7px;background:var(--panel);padding:9px 12px}button{cursor:pointer;font-weight:550}button:hover{border-color:var(--primary);background:var(--soft)}button:disabled{opacity:.45;cursor:default}a{color:var(--primary)}:focus-visible{outline:3px solid var(--primary);outline-offset:3px}header{display:flex;align-items:center;gap:30px;padding:22px 32px;border-bottom:1px solid var(--hraness-material-seam);background-color:var(--hraness-material-chrome-paint)}header p{color:var(--muted);margin:0}.brand{color:var(--text);font-size:22px;line-height:1.1;text-decoration:none;font-weight:650;letter-spacing:-.7px}.brand span{display:block;font-size:9px;letter-spacing:1.8px;color:var(--muted);margin-top:8px}.privacy{margin-left:auto;border:1px solid var(--line);border-radius:99px;padding:6px 12px;font-size:11px;color:var(--muted)}main{max-width:1600px;margin:auto;padding:36px 32px}h1{font-size:34px;line-height:1.2;letter-spacing:-1px;font-weight:550;margin:8px 0 24px}h2{font-size:21px;line-height:1.3;letter-spacing:-.5px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.unlock{max-width:600px;margin:7vh auto;padding:36px;background:var(--hraness-material-plane);border:1px solid var(--hraness-material-seam);border-radius:12px;color:var(--hraness-material-ink)}.unlock p{color:var(--muted)}.file-label{display:grid;gap:8px;margin:25px 0}.page-heading{display:flex;align-items:center;justify-content:space-between}.page-heading button{margin-bottom:20px}.filters{display:flex;gap:10px;align-items:end;margin-bottom:20px;flex-wrap:wrap}.filters label{display:grid;gap:5px;font-size:11px;color:var(--muted)}.filters input,.filters select{font-size:13px;color:var(--text);min-height:42px}.filters .search{flex:1;min-width:220px}.workspace-grid{display:grid;grid-template-columns:minmax(420px,1.4fr) minmax(320px,1fr);gap:20px;align-items:start}.records,.detail{border:1px solid var(--hraness-material-seam);background:var(--hraness-material-plane);border-radius:10px;color:var(--hraness-material-ink);overflow:hidden}.table-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px;flex-wrap:wrap}.table-toolbar>div{display:flex;align-items:center;gap:6px}.table-toolbar select,.table-toolbar button{padding:5px 8px;font-size:12px}.table-scroll{overflow:auto;max-height:65vh}table{width:100%;border-collapse:collapse;text-align:left}th{position:sticky;top:0;background:var(--soft);font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);z-index:1}th,td{padding:11px 16px;border-bottom:1px solid var(--line);vertical-align:top}td{font-size:12px}td small{display:block;color:var(--muted);margin-top:2px}td button{padding:0;border:0;background:none;text-align:left;font-size:13px;color:var(--text)}tr:hover{background:var(--soft)}tr.selected,tr.selected:hover{background:var(--hraness-material-warm-plane);color:var(--hraness-material-ink)}tr.selected td small,tr.selected td button{color:var(--hraness-material-ink)}.pagination{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;font-size:12px}.pagination button{font-size:12px;padding:6px 10px}.live-note{font-size:10px;color:var(--muted);padding:0 16px 12px;margin:0}.detail{padding:24px;position:sticky;top:20px;max-height:83vh;overflow:auto}.detail h2{font-size:25px}.detail dl{display:grid;grid-template-columns:90px 1fr;gap:8px;margin:22px 0}.detail dt{font-size:11px;color:var(--muted)}.detail dd{margin:0;font-size:12px;overflow-wrap:anywhere}.detail dd span{display:block}.pill{display:inline-block;border:1px solid var(--line);border-radius:99px;padding:2px 8px;margin:0 4px 5px 0;color:var(--muted);font-size:10px}.notes-heading{border-top:1px solid var(--line);padding-top:22px;margin-top:24px;display:flex;justify-content:space-between;align-items:center}.notes-heading h3{margin:0;font-weight:550}.note-card{border-bottom:1px solid var(--line);padding:16px 0}.note-card>button{font-size:12px;padding:5px 9px;margin:8px 6px 0 0}.note-meta{font-size:10px;color:var(--muted);margin:4px 0 10px}.note-title{font-size:14px;font-weight:600;margin:0}.markdown{font-size:13px;overflow-wrap:anywhere}.markdown h1,.markdown h2,.markdown h3{font-size:17px;margin:12px 0}.markdown pre{overflow:auto;background:var(--soft);padding:12px;border-radius:6px}.markdown img{display:none}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}.markdown p{margin:8px 0}.markdown td,.markdown th{padding:5px;position:static}.editor{display:grid;gap:10px}.editor label{display:grid;gap:4px;font-size:11px;color:var(--muted)}.editor textarea{min-height:230px;resize:vertical;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;tab-size:2}.editor-actions{display:flex;gap:6px;flex-wrap:wrap}.editor-actions button{font-size:12px}.primary{background:var(--primary);color:var(--primary-foreground)}.primary:hover{background:var(--primary);color:var(--primary-foreground)}.empty{padding:44px 22px;color:var(--muted);text-align:center}.empty h2{font-size:21px}.muted{color:var(--muted);font-size:12px}.danger{color:var(--danger)}#status{background:var(--panel);border:1px solid var(--line);padding:12px 16px;border-radius:8px}#status:empty{display:none}footer{color:var(--muted);font-size:11px;text-align:center;padding:20px}.skip{position:absolute;left:12px;top:-100px;padding:8px;background:var(--panel);z-index:5}.skip:focus{top:8px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
6879
+ @media(forced-colors:active){tr.selected,tr.selected:hover{background:Highlight;color:HighlightText}tr.selected td small,tr.selected td button{color:HighlightText}}
6635
6880
  @media(max-width:1000px){.workspace-grid{grid-template-columns:1fr}.detail{position:static;max-height:none}.privacy{display:none}}@media(max-width:600px){header{padding:18px;gap:18px}header p{font-size:11px}main{padding:26px 14px}.unlock{padding:22px;margin:25px 0}h1{font-size:28px}.filters{gap:8px}.filters label{flex:1;min-width:125px}.filters .search{flex-basis:100%}th,td{padding:10px}.detail{padding:18px}.table-toolbar{padding:10px}.page-heading{align-items:start}.page-heading button{font-size:11px}.table-scroll{max-height:50vh}}
6636
6881
  `;
6637
6882
  function workspaceClient() {
@@ -6701,6 +6946,7 @@ function workspaceClient() {
6701
6946
  el("rows").replaceChildren();
6702
6947
  for (const person of result.items) {
6703
6948
  const row = node("tr", "", selectedPerson === person.id ? "selected" : "");
6949
+ row.dataset.personId = String(person.id);
6704
6950
  const name = node("td");
6705
6951
  const open = node("button", person.displayName);
6706
6952
  open.type = "button";
@@ -6945,6 +7191,9 @@ function workspaceClient() {
6945
7191
  }
6946
7192
  activeEditor = null;
6947
7193
  selectedPerson = id;
7194
+ for (const row of el("rows").querySelectorAll("tr[data-person-id]")) {
7195
+ row.classList.toggle("selected", row.dataset.personId === String(id));
7196
+ }
6948
7197
  target.replaceChildren();
6949
7198
  target.append(node("span", `PERSON ${id} \xB7 ${result.memberCount} SOURCE RECORD${result.memberCount === 1 ? "" : "S"}`, "eyebrow"), node("h2", result.contact.displayName));
6950
7199
  const pills = node("div");
@@ -11484,6 +11733,57 @@ function addConfirmedPerson(database, args) {
11484
11733
  }
11485
11734
  }
11486
11735
 
11736
+ // src/local/menubar.ts
11737
+ import { existsSync as existsSync3 } from "fs";
11738
+ import { dirname as dirname4, resolve as resolve3 } from "path";
11739
+ var SETTLE_MS = 400;
11740
+ function resolveMenubarBinary(environment = process.env) {
11741
+ const candidates = [
11742
+ environment.PEOPLEBLADE_DESKTOP,
11743
+ resolve3(dirname4(process.execPath), "peopleblade-menubar"),
11744
+ resolve3(import.meta.dir, "../../desktop/target/release/peopleblade-menubar"),
11745
+ resolve3(import.meta.dir, "../../desktop/target/debug/peopleblade-menubar")
11746
+ ];
11747
+ for (const candidate of candidates) {
11748
+ if (candidate !== undefined && candidate !== "" && existsSync3(candidate))
11749
+ return candidate;
11750
+ }
11751
+ return null;
11752
+ }
11753
+ async function launchMenubar(asJson) {
11754
+ const binary = resolveMenubarBinary();
11755
+ if (binary === null) {
11756
+ const message = "The PeopleBlade menu bar is not installed. Build it with `cargo build --release --manifest-path desktop/Cargo.toml` or set PEOPLEBLADE_DESKTOP.";
11757
+ if (asJson)
11758
+ console.log(JSON.stringify({ error: message }));
11759
+ else
11760
+ console.error(message);
11761
+ return 1;
11762
+ }
11763
+ let child;
11764
+ try {
11765
+ child = Bun.spawn([binary], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
11766
+ } catch {
11767
+ console.error("The PeopleBlade menu bar could not start.");
11768
+ return 1;
11769
+ }
11770
+ child.unref();
11771
+ const settled = await Promise.race([
11772
+ child.exited.then((code) => code),
11773
+ Bun.sleep(SETTLE_MS).then(() => null)
11774
+ ]);
11775
+ if (settled !== null && settled !== 0) {
11776
+ console.error("The PeopleBlade menu bar exited during startup.");
11777
+ return 1;
11778
+ }
11779
+ const alreadyRunning = settled === 0;
11780
+ if (asJson)
11781
+ console.log(JSON.stringify({ running: true, alreadyRunning }));
11782
+ else
11783
+ console.log(alreadyRunning ? "PeopleBlade menu bar is already running." : "PeopleBlade menu bar is running.");
11784
+ return 0;
11785
+ }
11786
+
11487
11787
  // src/local/providers/apple.ts
11488
11788
  import { Database as Database3 } from "bun:sqlite";
11489
11789
  import { createHash as createHash3 } from "crypto";
@@ -11493,7 +11793,7 @@ import {
11493
11793
  realpathSync as realpathSync4
11494
11794
  } from "fs";
11495
11795
  import { homedir as homedir2 } from "os";
11496
- import { join as join5, resolve as resolve3 } from "path";
11796
+ import { join as join5, resolve as resolve4 } from "path";
11497
11797
  var EMAIL_PATTERN2 = /^(?=[\x21-\x7E]+$)[^@\s]+@[^@\s]+\.[^@\s]+$/u;
11498
11798
  function clean(value) {
11499
11799
  if (typeof value !== "string")
@@ -11696,7 +11996,7 @@ function rowsByOwner(database, table, names) {
11696
11996
  return grouped;
11697
11997
  }
11698
11998
  function sourceDatabasePaths(directory) {
11699
- const base = realpathSync4(resolve3(directory));
11999
+ const base = realpathSync4(resolve4(directory));
11700
12000
  const sourceDirectory = join5(base, "Sources");
11701
12001
  const sourceIdentity = lstatSync5(sourceDirectory);
11702
12002
  if (!sourceIdentity.isDirectory() || sourceIdentity.isSymbolicLink()) {
@@ -12114,7 +12414,8 @@ var methodSchema = z10.object({
12114
12414
  var contactSchema = z10.object({
12115
12415
  person: personSchema,
12116
12416
  resource: resourceSchema,
12117
- methods: z10.array(methodSchema).max(1000)
12417
+ methods: z10.array(methodSchema).max(1000),
12418
+ existingIdentityPolicy: z10.literal("preserve").optional()
12118
12419
  }).strict().superRefine((contact, context) => {
12119
12420
  const coordinates = new Set;
12120
12421
  contact.methods.forEach((method, index) => {
@@ -12384,6 +12685,7 @@ function materializeContactSourceSnapshot(database, snapshot, sourceRealmId, sou
12384
12685
  ordinal += 1;
12385
12686
  const prior = getRow3(database, `SELECT id,person_id,source_realm_id FROM provider_resources
12386
12687
  WHERE provider=? AND account_key=? AND resource_type=? AND resource_id=?`, snapshot.provider, snapshot.accountKey, contact.resource.type, contact.resource.id);
12688
+ const preserveIdentity = prior !== null && contact.existingIdentityPolicy === "preserve";
12387
12689
  let personId;
12388
12690
  let providerResourceId;
12389
12691
  if (prior === null) {
@@ -12403,47 +12705,54 @@ function materializeContactSourceSnapshot(database, snapshot, sourceRealmId, sou
12403
12705
  }
12404
12706
  personId = prior.person_id;
12405
12707
  providerResourceId = prior.id;
12406
- const person = contact.person;
12407
- run2(database, `UPDATE people SET display_name=?,given_name=?,middle_name=?,family_name=?,name_prefix=?,
12408
- name_suffix=?,nickname=?,organization=?,department=?,title=?,birthday=?,metadata_json=? WHERE id=?`, person.displayName, person.givenName, person.middleName, person.familyName, person.namePrefix, person.nameSuffix, person.nickname, person.organization, person.department, person.title, person.birthday, canonicalJson(person.metadata), personId);
12409
- run2(database, `UPDATE provider_resources SET username=?,profile_url=?,profile_url_identity_eligible=?,name_identity_eligible=?,display_name=?,active=1,
12410
- last_seen_run_id=?,metadata_json=?,source_realm_id=coalesce(source_realm_id,?) WHERE id=?`, contact.resource.username, contact.resource.profileUrl, contact.resource.profileUrlIdentityEligible ? 1 : 0, contact.resource.nameIdentityEligible ? 1 : 0, contact.resource.displayName, sourceRunId, canonicalJson(contact.resource.metadata), sourceRealmId, providerResourceId);
12411
- }
12412
- run2(database, "UPDATE contact_methods SET active=0 WHERE provider_resource_id=?", providerResourceId);
12413
- for (const method of contact.methods) {
12414
- run2(database, `INSERT INTO contact_methods(
12415
- person_id,kind,value,normalized_value,label,is_primary,confidence,first_seen_run_id,last_seen_run_id,
12416
- metadata_json,provider_resource_id,active,identity_eligible
12417
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?)
12418
- ON CONFLICT(person_id,kind,normalized_value,label) DO UPDATE SET
12419
- value=excluded.value,is_primary=excluded.is_primary,confidence=excluded.confidence,
12420
- last_seen_run_id=excluded.last_seen_run_id,metadata_json=excluded.metadata_json,
12421
- provider_resource_id=excluded.provider_resource_id,active=1,
12422
- identity_eligible=excluded.identity_eligible`, personId, method.kind, method.value, method.normalizedValue, method.label, method.primary ? 1 : 0, method.confidence, sourceRunId, sourceRunId, canonicalJson(method.metadata), providerResourceId, method.identityEligible ? 1 : 0);
12423
- }
12424
- run2(database, "UPDATE person_field_observations SET active=0 WHERE provider_resource_id=?", providerResourceId);
12425
- const observedFields = [
12426
- ["display_name", contact.person.displayName],
12427
- ["given_name", contact.person.givenName],
12428
- ["middle_name", contact.person.middleName],
12429
- ["family_name", contact.person.familyName],
12430
- ["name_prefix", contact.person.namePrefix],
12431
- ["name_suffix", contact.person.nameSuffix],
12432
- ["nickname", contact.person.nickname],
12433
- ["organization", contact.person.organization],
12434
- ["department", contact.person.department],
12435
- ["title", contact.person.title],
12436
- ["birthday", contact.person.birthday]
12437
- ];
12438
- for (const [field, fieldValue] of observedFields) {
12439
- if (fieldValue === null)
12440
- continue;
12441
- run2(database, `INSERT INTO person_field_observations(
12442
- provider_resource_id,field,value,basis,priority,active,first_seen_run_id,last_seen_run_id,metadata_json
12443
- ) VALUES (?,?,?,?,?,1,?,?,?)
12444
- ON CONFLICT(provider_resource_id,field,value,basis) DO UPDATE SET
12445
- priority=excluded.priority,active=1,last_seen_run_id=excluded.last_seen_run_id,
12446
- metadata_json=excluded.metadata_json`, providerResourceId, field, fieldValue, contact.person.observationBasis, contact.person.observationPriority, sourceRunId, sourceRunId, canonicalJson(contact.person.metadata));
12708
+ if (preserveIdentity) {
12709
+ run2(database, `UPDATE provider_resources SET active=1,last_seen_run_id=?,
12710
+ source_realm_id=coalesce(source_realm_id,?) WHERE id=?`, sourceRunId, sourceRealmId, providerResourceId);
12711
+ } else {
12712
+ const person = contact.person;
12713
+ run2(database, `UPDATE people SET display_name=?,given_name=?,middle_name=?,family_name=?,name_prefix=?,
12714
+ name_suffix=?,nickname=?,organization=?,department=?,title=?,birthday=?,metadata_json=? WHERE id=?`, person.displayName, person.givenName, person.middleName, person.familyName, person.namePrefix, person.nameSuffix, person.nickname, person.organization, person.department, person.title, person.birthday, canonicalJson(person.metadata), personId);
12715
+ run2(database, `UPDATE provider_resources SET username=?,profile_url=?,profile_url_identity_eligible=?,name_identity_eligible=?,display_name=?,active=1,
12716
+ last_seen_run_id=?,metadata_json=?,source_realm_id=coalesce(source_realm_id,?) WHERE id=?`, contact.resource.username, contact.resource.profileUrl, contact.resource.profileUrlIdentityEligible ? 1 : 0, contact.resource.nameIdentityEligible ? 1 : 0, contact.resource.displayName, sourceRunId, canonicalJson(contact.resource.metadata), sourceRealmId, providerResourceId);
12717
+ }
12718
+ }
12719
+ if (!preserveIdentity) {
12720
+ run2(database, "UPDATE contact_methods SET active=0 WHERE provider_resource_id=?", providerResourceId);
12721
+ for (const method of contact.methods) {
12722
+ run2(database, `INSERT INTO contact_methods(
12723
+ person_id,kind,value,normalized_value,label,is_primary,confidence,first_seen_run_id,last_seen_run_id,
12724
+ metadata_json,provider_resource_id,active,identity_eligible
12725
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?)
12726
+ ON CONFLICT(person_id,kind,normalized_value,label) DO UPDATE SET
12727
+ value=excluded.value,is_primary=excluded.is_primary,confidence=excluded.confidence,
12728
+ last_seen_run_id=excluded.last_seen_run_id,metadata_json=excluded.metadata_json,
12729
+ provider_resource_id=excluded.provider_resource_id,active=1,
12730
+ identity_eligible=excluded.identity_eligible`, personId, method.kind, method.value, method.normalizedValue, method.label, method.primary ? 1 : 0, method.confidence, sourceRunId, sourceRunId, canonicalJson(method.metadata), providerResourceId, method.identityEligible ? 1 : 0);
12731
+ }
12732
+ run2(database, "UPDATE person_field_observations SET active=0 WHERE provider_resource_id=?", providerResourceId);
12733
+ const observedFields = [
12734
+ ["display_name", contact.person.displayName],
12735
+ ["given_name", contact.person.givenName],
12736
+ ["middle_name", contact.person.middleName],
12737
+ ["family_name", contact.person.familyName],
12738
+ ["name_prefix", contact.person.namePrefix],
12739
+ ["name_suffix", contact.person.nameSuffix],
12740
+ ["nickname", contact.person.nickname],
12741
+ ["organization", contact.person.organization],
12742
+ ["department", contact.person.department],
12743
+ ["title", contact.person.title],
12744
+ ["birthday", contact.person.birthday]
12745
+ ];
12746
+ for (const [field, fieldValue] of observedFields) {
12747
+ if (fieldValue === null)
12748
+ continue;
12749
+ run2(database, `INSERT INTO person_field_observations(
12750
+ provider_resource_id,field,value,basis,priority,active,first_seen_run_id,last_seen_run_id,metadata_json
12751
+ ) VALUES (?,?,?,?,?,1,?,?,?)
12752
+ ON CONFLICT(provider_resource_id,field,value,basis) DO UPDATE SET
12753
+ priority=excluded.priority,active=1,last_seen_run_id=excluded.last_seen_run_id,
12754
+ metadata_json=excluded.metadata_json`, providerResourceId, field, fieldValue, contact.person.observationBasis, contact.person.observationPriority, sourceRunId, sourceRunId, canonicalJson(contact.person.metadata));
12755
+ }
12447
12756
  }
12448
12757
  if (writeSourceRecords) {
12449
12758
  run2(database, `INSERT INTO source_records(
@@ -12519,7 +12828,7 @@ function importContactSourceSnapshot(database, value) {
12519
12828
  source_rows: snapshot.contacts.length,
12520
12829
  people_created: peopleCreated,
12521
12830
  people_matched: peopleMatched,
12522
- methods_touched: snapshot.contacts.reduce((sum, contact) => sum + contact.methods.length, 0),
12831
+ methods_touched: snapshot.contacts.reduce((sum, contact) => sum + (contact.existingIdentityPolicy === "preserve" && existingCoordinates.has(`${contact.resource.type}\x00${contact.resource.id}`) ? 0 : contact.methods.length), 0),
12523
12832
  interactions_touched: snapshot.interactions.length,
12524
12833
  resources_removed: resourcesRemoved,
12525
12834
  reconciled: reconcile,
@@ -12589,7 +12898,7 @@ function sha2563(value) {
12589
12898
 
12590
12899
  // node_modules/@hraness/ghostget/dist/client.js
12591
12900
  import { spawn, spawnSync } from "child_process";
12592
- import { existsSync as existsSync3 } from "fs";
12901
+ import { existsSync as existsSync4 } from "fs";
12593
12902
  import { fileURLToPath as fileURLToPath2 } from "url";
12594
12903
  import { types as nodeTypes } from "util";
12595
12904
  var PORTABLE_OPERATION_IDENTITY_VERSION = 1;
@@ -12887,10 +13196,10 @@ function boundedMessage(value) {
12887
13196
  }
12888
13197
  function cliSourcePath() {
12889
13198
  const besideSource = fileURLToPath2(new URL("./cli.ts", import.meta.url));
12890
- if (existsSync3(besideSource))
13199
+ if (existsSync4(besideSource))
12891
13200
  return besideSource;
12892
13201
  const packagedSource = fileURLToPath2(new URL("../src/cli.ts", import.meta.url));
12893
- if (existsSync3(packagedSource))
13202
+ if (existsSync4(packagedSource))
12894
13203
  return packagedSource;
12895
13204
  throw new Error("the installed Ghostget CLI source is unavailable");
12896
13205
  }
@@ -13901,7 +14210,7 @@ var GHOSTGET_VERSION = "0.17.3";
13901
14210
 
13902
14211
  // node_modules/@hraness/ghostget/dist/beeper-client.js
13903
14212
  import { spawnSync as spawnSync2 } from "child_process";
13904
- import { existsSync as existsSync4 } from "fs";
14213
+ import { existsSync as existsSync5 } from "fs";
13905
14214
  import { fileURLToPath as fileURLToPath3 } from "url";
13906
14215
  import { types as nodeTypes3 } from "util";
13907
14216
  import { types as nodeTypes2 } from "util";
@@ -18347,10 +18656,10 @@ function fail22(message) {
18347
18656
  }
18348
18657
  function cliSourcePath2() {
18349
18658
  const besideSource = fileURLToPath3(new URL("./cli.ts", import.meta.url));
18350
- if (existsSync4(besideSource))
18659
+ if (existsSync5(besideSource))
18351
18660
  return besideSource;
18352
18661
  const packagedSource = fileURLToPath3(new URL("../src/cli.ts", import.meta.url));
18353
- if (existsSync4(packagedSource))
18662
+ if (existsSync5(packagedSource))
18354
18663
  return packagedSource;
18355
18664
  return fail22("the installed Ghostget CLI source is unavailable");
18356
18665
  }
@@ -19425,6 +19734,28 @@ function contactMethods(contact, service) {
19425
19734
  });
19426
19735
  return methods;
19427
19736
  }
19737
+ function contactsWithRelationshipParticipants(directoryContacts, interactions) {
19738
+ const contacts = [...directoryContacts];
19739
+ const known = new Set(contacts.map((contact) => contact.id));
19740
+ const relationshipOnlyIds = new Set;
19741
+ for (const interaction of [...interactions].sort((left, right) => left.contactId.localeCompare(right.contactId))) {
19742
+ if (known.has(interaction.contactId))
19743
+ continue;
19744
+ contacts.push({
19745
+ accountId: interaction.accountId,
19746
+ id: interaction.contactId,
19747
+ fullName: null,
19748
+ username: null,
19749
+ phoneNumber: null,
19750
+ email: null,
19751
+ isSelf: false,
19752
+ cannotMessage: null
19753
+ });
19754
+ known.add(interaction.contactId);
19755
+ relationshipOnlyIds.add(interaction.contactId);
19756
+ }
19757
+ return { contacts, relationshipOnlyIds };
19758
+ }
19428
19759
  function sameExecution(left, right) {
19429
19760
  return left.authId === right.authId && left.authSha256 === right.authSha256 && left.adapterSha256 === right.adapterSha256 && left.contractSha256 === right.contractSha256;
19430
19761
  }
@@ -19737,8 +20068,12 @@ function syncBeeperContacts(database, options = {}) {
19737
20068
  if (pageAccount === undefined)
19738
20069
  throw new Error("Beeper account disappeared during sequential sync.");
19739
20070
  const service = normalizeService(pageAccount);
19740
- const contacts = page.contacts.filter((contact) => contact.isSelf !== true && contact.id !== pageAccount.user.id);
19741
- const skippedSelf = page.contacts.length - contacts.length;
20071
+ const directoryContacts = page.contacts.filter((contact) => contact.isSelf !== true && contact.id !== pageAccount.user.id);
20072
+ const skippedSelf = page.contacts.length - directoryContacts.length;
20073
+ const accountInteractions = interactionExport?.output.interactions.filter((interaction) => interaction.accountId === pageAccount.accountId) ?? [];
20074
+ const relationshipContacts = contactsWithRelationshipParticipants(directoryContacts, accountInteractions);
20075
+ const contacts = relationshipContacts.contacts;
20076
+ const contactsObserved = page.contacts.length + contacts.length - directoryContacts.length;
19742
20077
  const relationshipProjection = interactionExport === null ? null : projectBeeperContactInteractions(interactionExport.output, contacts.map((contact) => ({
19743
20078
  rawAccountId: pageAccount.accountId,
19744
20079
  accountKey: realmAccountKey,
@@ -19796,7 +20131,9 @@ function syncBeeperContacts(database, options = {}) {
19796
20131
  sourceRealm: sourceRealm(pageAccount),
19797
20132
  contacts: contacts.map((contact) => {
19798
20133
  const display = contactDisplayName(contact);
20134
+ const relationshipOnly = relationshipContacts.relationshipOnlyIds.has(contact.id);
19799
20135
  return {
20136
+ ...relationshipOnly ? { existingIdentityPolicy: "preserve" } : {},
19800
20137
  person: {
19801
20138
  displayName: display.value,
19802
20139
  givenName: null,
@@ -19811,7 +20148,10 @@ function syncBeeperContacts(database, options = {}) {
19811
20148
  birthday: null,
19812
20149
  observationBasis: display.basis,
19813
20150
  observationPriority: display.priority,
19814
- metadata: { createdBy: "beeper-contact", service }
20151
+ metadata: {
20152
+ createdBy: relationshipOnly ? "beeper-direct-interaction" : "beeper-contact",
20153
+ service
20154
+ }
19815
20155
  },
19816
20156
  resource: {
19817
20157
  type: "user",
@@ -19824,6 +20164,7 @@ function syncBeeperContacts(database, options = {}) {
19824
20164
  metadata: {
19825
20165
  authority: "beeper",
19826
20166
  service,
20167
+ ...relationshipOnly ? { relationshipOnly: true } : {},
19827
20168
  cannotMessage: contact.cannotMessage,
19828
20169
  isSelf: contact.isSelf,
19829
20170
  emailVerification: "not-guaranteed",
@@ -19855,7 +20196,7 @@ function syncBeeperContacts(database, options = {}) {
19855
20196
  accountResults.push({
19856
20197
  realm_id: realm.id,
19857
20198
  service,
19858
- contacts_seen: page.contacts.length,
20199
+ contacts_seen: contactsObserved,
19859
20200
  contacts_imported: imported.people_created,
19860
20201
  contacts_matched: imported.people_matched,
19861
20202
  contacts_skipped_self: skippedSelf,
@@ -19864,7 +20205,7 @@ function syncBeeperContacts(database, options = {}) {
19864
20205
  truncated: !page.localPageComplete,
19865
20206
  cached: imported.cached
19866
20207
  });
19867
- contactsSeen += page.contacts.length;
20208
+ contactsSeen += contactsObserved;
19868
20209
  contactsImported += imported.people_created;
19869
20210
  contactsMatched += imported.people_matched;
19870
20211
  contactsSkippedSelf += skippedSelf;
@@ -19879,7 +20220,7 @@ function syncBeeperContacts(database, options = {}) {
19879
20220
  ordinal: index + 1,
19880
20221
  accounts: orderedAccounts.length,
19881
20222
  service,
19882
- contacts: page.contacts.length,
20223
+ contacts: contactsObserved,
19883
20224
  truncated: !page.localPageComplete
19884
20225
  });
19885
20226
  }
@@ -21470,7 +21811,7 @@ import { Database as Database4 } from "bun:sqlite";
21470
21811
  import { createHash as createHash6 } from "crypto";
21471
21812
  import { lstatSync as lstatSync6, realpathSync as realpathSync5 } from "fs";
21472
21813
  import { homedir as homedir3 } from "os";
21473
- import { join as join6, resolve as resolve4 } from "path";
21814
+ import { join as join6, resolve as resolve5 } from "path";
21474
21815
  var DEFAULT_IMESSAGE_DATABASE = join6(homedir3(), "Library", "Messages", "chat.db");
21475
21816
  var DEFAULT_IMESSAGE_ACCOUNT = "imessage-main";
21476
21817
  var DEFAULT_IMESSAGE_PAGE_SIZE = 1000;
@@ -21914,7 +22255,7 @@ function syncIMessageRelationships(database, options = {}) {
21914
22255
  const pageSize = options.pageSize ?? DEFAULT_IMESSAGE_PAGE_SIZE;
21915
22256
  if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE)
21916
22257
  throw new Error(`iMessage page size must be between 1 and ${MAX_PAGE_SIZE}`);
21917
- const requestedPath = resolve4(options.messagesDatabase ?? DEFAULT_IMESSAGE_DATABASE);
22258
+ const requestedPath = resolve5(options.messagesDatabase ?? DEFAULT_IMESSAGE_DATABASE);
21918
22259
  const identity = lstatSync6(requestedPath);
21919
22260
  const uid = typeof process.getuid === "function" ? process.getuid() : null;
21920
22261
  if (!identity.isFile() || identity.isSymbolicLink() || identity.size < 1 || identity.size > MAX_DATABASE_BYTES || uid !== null && identity.uid !== uid) {
@@ -23000,7 +23341,7 @@ function instagramStatsAll(database) {
23000
23341
  // src/local/providers/x-archive.ts
23001
23342
  import { createHash as createHash8 } from "crypto";
23002
23343
  import { closeSync as closeSync7, constants as constants7, fstatSync as fstatSync6, openSync as openSync7, readSync as readSync2 } from "fs";
23003
- import { resolve as resolve5 } from "path";
23344
+ import { resolve as resolve6 } from "path";
23004
23345
 
23005
23346
  // src/local/archive/x-zip-file.ts
23006
23347
  import { readSync } from "fs";
@@ -23973,7 +24314,7 @@ function sha256Descriptor(descriptor, size) {
23973
24314
  async function readArchive2(path) {
23974
24315
  if (typeof path !== "string" || path.length < 1 || path.includes("\x00"))
23975
24316
  throw new Error("X archive path is invalid");
23976
- const locator = resolve5(path);
24317
+ const locator = resolve6(path);
23977
24318
  const descriptor = openSync7(locator, constants7.O_RDONLY | (constants7.O_NOFOLLOW ?? 0));
23978
24319
  try {
23979
24320
  const before = fstatSync6(descriptor, { bigint: true });
@@ -26255,7 +26596,20 @@ function syncWhatsAppRelationships(database, options = {}) {
26255
26596
  }
26256
26597
 
26257
26598
  // src/cli/version.ts
26258
- var peoplebladeVersion = "0.3.3";
26599
+ var peoplebladeVersion = "0.3.5";
26600
+
26601
+ // src/cli/intro.ts
26602
+ function terminalIntro(terminal) {
26603
+ if (terminal.isTTY !== true || terminal.term === "dumb" || (terminal.columns ?? 80) < 48)
26604
+ return "";
26605
+ return ` .--.--.
26606
+ /______/_ peopleblade
26607
+ | o --- | Your contacts, notes, and context.
26608
+ |___ --- |
26609
+ |____|
26610
+
26611
+ `;
26612
+ }
26259
26613
 
26260
26614
  // src/cli/main.ts
26261
26615
  var usage = `PeopleBlade \u2014 local-first contact intelligence
@@ -26264,6 +26618,8 @@ Usage: peopleblade [--db PATH] <command>
26264
26618
 
26265
26619
  Core:
26266
26620
  init Create or migrate the local database
26621
+ menubar Launch the detached menu-bar companion
26622
+ outputs Print the agent outputs directory
26267
26623
  backup [PATH] Create a consistent private SQLite backup
26268
26624
  migrate rolodex --from PATH Back up and migrate a legacy Rolodex database once
26269
26625
  list [--search TEXT] [--limit N] Search the local contact book
@@ -26408,7 +26764,7 @@ function yesNo(value, label) {
26408
26764
  async function readNoteMarkdown(path) {
26409
26765
  const maximum = 65536;
26410
26766
  if (path !== "-") {
26411
- const descriptor = openSync9(resolve6(path), constants9.O_RDONLY | constants9.O_NONBLOCK | (constants9.O_NOFOLLOW ?? 0));
26767
+ const descriptor = openSync9(resolve7(path), constants9.O_RDONLY | constants9.O_NONBLOCK | (constants9.O_NOFOLLOW ?? 0));
26412
26768
  try {
26413
26769
  const before = fstatSync8(descriptor);
26414
26770
  if (!before.isFile() || before.nlink !== 1 || before.size > maximum || typeof process.getuid === "function" && before.uid !== process.getuid()) {
@@ -26490,6 +26846,8 @@ async function main(argv) {
26490
26846
  const asJson = flag(args, "--json");
26491
26847
  const autoAccept = flag(args, "--auto-accept");
26492
26848
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
26849
+ if (!asJson)
26850
+ process.stdout.write(terminalIntro({ isTTY: process.stdout.isTTY, columns: process.stdout.columns, term: process.env.TERM }));
26493
26851
  console.log(usage);
26494
26852
  return;
26495
26853
  }
@@ -26516,13 +26874,30 @@ async function main(argv) {
26516
26874
  print({ database: databasePath, initialized: true, ...result }, asJson);
26517
26875
  return;
26518
26876
  }
26877
+ if (command === "menubar") {
26878
+ if (args.length !== 1)
26879
+ fail3("menubar takes no arguments.");
26880
+ process.exitCode = await launchMenubar(asJson);
26881
+ return;
26882
+ }
26883
+ if (command === "outputs") {
26884
+ if (args.length !== 1)
26885
+ fail3("outputs takes no arguments.");
26886
+ const directory = join7(peoplebladeDirectory(), "outputs");
26887
+ mkdirSync3(directory, { recursive: true, mode: 448 });
26888
+ if (asJson)
26889
+ print({ outputs: directory }, true);
26890
+ else
26891
+ console.log(directory);
26892
+ return;
26893
+ }
26519
26894
  if (command === "migrate" && subcommand === "rolodex") {
26520
26895
  const options = [...rest];
26521
26896
  const source = valueAfter(options, "--from") ?? fail3("migrate rolodex requires --from PATH.");
26522
26897
  const backup = valueAfter(options, "--backup") ?? standardBackupPath("legacy-rolodex", databasePath);
26523
26898
  if (options.length > 0)
26524
26899
  fail3(`Unknown argument: ${options[0]}`);
26525
- if (!existsSync5(source))
26900
+ if (!existsSync6(source))
26526
26901
  fail3(`Legacy Rolodex does not exist: ${source}`);
26527
26902
  backupLocalDatabase(source, backup);
26528
26903
  print({ database: databasePath, legacyBackup: backup, ...migrateLegacyRolodex(source, databasePath) }, asJson);
@@ -26556,7 +26931,7 @@ ${verification}`) });
26556
26931
  print(await signOutCloud(), asJson);
26557
26932
  return;
26558
26933
  }
26559
- if (!existsSync5(databasePath))
26934
+ if (!existsSync6(databasePath))
26560
26935
  fail3("Run `peopleblade init` or `peopleblade migrate rolodex --from PATH` first.");
26561
26936
  if (command === "backup") {
26562
26937
  const destination = subcommand ?? standardBackupPath("manual", databasePath);
@@ -26994,7 +27369,7 @@ ${verification}`) });
26994
27369
  const from = valueAfter(options, "--from") ?? fail3("notes import requires --from granola.json or --from -.");
26995
27370
  if (options.length)
26996
27371
  fail3(`Unknown argument: ${options[0]}`);
26997
- const payload = from === "-" ? parseGranolaImportJson(await new Response(Bun.stdin).text()) : readGranolaImportFile(resolve6(from));
27372
+ const payload = from === "-" ? parseGranolaImportJson(await new Response(Bun.stdin).text()) : readGranolaImportFile(resolve7(from));
26998
27373
  printImport(importGranolaMeetings(database, payload), true);
26999
27374
  return;
27000
27375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/peopleblade",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Local-first, source-aware contact intelligence for people and agents",
5
5
  "type": "module",
6
6
  "bin": {