diff --git a/Directory.Packages.props b/Directory.Packages.props
index 82f88b4..0a39d46 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -28,10 +28,10 @@
-
-
-
-
+
+
+
+
diff --git a/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs b/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
index b74cffe..037fba0 100644
--- a/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
+++ b/NexusKit.Modules.InternalData/History/PlayerHistoryKind.cs
@@ -29,4 +29,16 @@ public enum PlayerHistoryKind : byte
/// tag-string OldValue/NewValue payloads on those rows render through
/// the same UI path; new rows carry FC Lodestone ids.
FreeCompanyChange = 4,
+
+ /// The character's in-game search comment (Search Info) changed.
+ /// OldValue / NewValue carry the raw text; either may be null, which is how
+ /// "set for the first time" and "cleared" are expressed.
+ /// Unlike every kind above, this one is not produced by the
+ /// observation diff — the search comment is not in the object table. It is
+ /// written by the Examine capture path, so a row only ever appears for a
+ /// character the user examined at least twice with a different comment in
+ /// between (or once, if they had none on file before).
+ /// Not the Lodestone biography — that is a different datum and is not
+ /// tracked here.
+ SearchCommentChange = 5,
}
diff --git a/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs b/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
index d0d9d65..b6199d6 100644
--- a/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
+++ b/NexusKit.Modules.InternalData/InternalDataServiceCollectionExtensions.cs
@@ -36,6 +36,11 @@ public static IServiceCollection AddNexusKitInternalData(this IServiceCollection
services.AddSingleton();
services.AddSingleton(sp => sp.GetRequiredService());
+ // Examine-time search-comment capture. Needs an eager resolve like the
+ // watcher — its ctor is where the subscription happens. Depends on
+ // IInspectSearchCommentWatcher from NexusKit.GameData.
+ services.AddSingleton();
+
return services;
}
}
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs b/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
index ce4514e..a7a8b51 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalDataEntityModule.cs
@@ -34,6 +34,7 @@ public void ConfigureEntities(ModelBuilder modelBuilder)
e.Property(x => x.CurrentMinionId).HasColumnName("current_minion_id");
e.Property(x => x.OnlineStatusId).HasColumnName("online_status_id");
e.Property(x => x.Notes).HasColumnName("notes");
+ e.Property(x => x.SearchComment).HasColumnName("search_comment");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasIndex(x => x.Name);
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs b/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
index 02364ed..871b261 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalDataMigrations.cs
@@ -30,6 +30,7 @@ internal sealed class InternalDataMigrations : IMigrationModule
new AddEncountersDropSeenCount(),
new AddObservedPlayerNotesColumn(),
new AddEncounterWorldIdColumn(),
+ new AddObservedPlayerSearchCommentColumn(),
};
}
@@ -222,6 +223,39 @@ await ctx.Database.ExecuteSqlRawAsync(
}
}
+internal sealed class AddObservedPlayerSearchCommentColumn : IMigration
+{
+ public string Id => "20260803_observed_player_search_comment_column";
+
+ // Same shape as the notes column above: declared on the entity module, so
+ // fresh installs get it from EnsureCreated and this is stamped applied on
+ // baseline. Upgrade installs take the ALTER TABLE, PRAGMA-gated for
+ // idempotence. Existing rows stay NULL — the search comment only ever
+ // arrives via the Examine capture path, never from an observation tick,
+ // so there is nothing to backfill from.
+ public async Task UpAsync(DbContext ctx, CancellationToken ct)
+ {
+ var connection = ctx.Database.GetDbConnection();
+ if (connection.State != System.Data.ConnectionState.Open)
+ await connection.OpenAsync(ct).ConfigureAwait(false);
+
+ await using (var probe = connection.CreateCommand())
+ {
+ probe.CommandText = "PRAGMA table_info(nexus_internal_observed_player);";
+ await using var reader = await probe.ExecuteReaderAsync(ct).ConfigureAwait(false);
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ if (string.Equals(reader.GetString(1), "search_comment", StringComparison.OrdinalIgnoreCase))
+ return;
+ }
+ }
+
+ await ctx.Database.ExecuteSqlRawAsync(
+ "ALTER TABLE nexus_internal_observed_player ADD COLUMN search_comment TEXT;",
+ ct).ConfigureAwait(false);
+ }
+}
+
internal sealed class AddObservedPlayerLastSeenIndex : IMigration
{
public string Id => "20260515_observed_player_last_seen_idx";
diff --git a/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs b/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
index 53a2693..5693981 100644
--- a/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
+++ b/NexusKit.Modules.InternalData/Persistence/InternalObservedPlayerEntity.cs
@@ -50,5 +50,15 @@ public sealed class InternalObservedPlayerEntity
/// touch notes leave existing text alone.
public string? Notes { get; set; }
+ /// The character's in-game search comment (Search Info), captured
+ /// when the user examined them. Null when never captured or when the
+ /// character has none set — the two are deliberately not distinguished.
+ /// Not part of the observation tick: the game's Character struct
+ /// carries no such field, so this is only ever written by the Examine
+ /// capture path, never by the object-table scan.
+ /// Do not confuse with the Lodestone biography, which lives on
+ /// PlayerProfileEntity.Bio in ExternalData.
+ public string? SearchComment { get; set; }
+
public DateTime UpdatedAt { get; set; }
}
diff --git a/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs b/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
index 6ec9d77..d35fd95 100644
--- a/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
+++ b/NexusKit.Modules.InternalData/Persistence/PlayerFilterViewBuilder.cs
@@ -49,6 +49,7 @@ CREATE VIEW {ViewName} AS
o.online_status_id AS online_status_id,
o.company_tag AS company_tag,
o.notes AS notes,
+ o.search_comment AS search_comment,
e.data_center_id AS external_data_center_id,
p.free_company_lodestone_id AS free_company_lodestone_id,
fc.name AS fc_name,
diff --git a/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs b/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
index 9212b34..1866a6a 100644
--- a/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
+++ b/NexusKit.Modules.InternalData/Players/IInternalDataPlayerWatcher.cs
@@ -63,9 +63,20 @@ public interface IInternalDataPlayerWatcher
/// so notes saves don't shift the observation-freshness signal.
Task UpdateNotesAsync(ulong contentId, string? notes, CancellationToken ct = default);
+ /// Persists the character's in-game search comment, captured by the
+ /// Examine path rather than by an observation tick (the game's Character
+ /// struct carries no such field). Pass null or whitespace to record that they
+ /// have none. Leaves UpdatedAt alone for the same reason as
+ /// — examining somebody is not a sighting.
+ /// The returned reports whether the
+ /// value actually moved and what it was before, so the caller can write a
+ /// history row without a second read.
+ Task SetSearchCommentAsync(
+ ulong contentId, string? searchComment, CancellationToken ct = default);
+
/// Lazy-loads the heavy fields not carried on the in-memory
- /// : full Customize bytes and Notes content.
- /// Single indexed lookup; safe to call from the UI thread. Returns null
- /// when no observed_player row exists for the id.
+ /// : full Customize bytes, Notes content and the
+ /// captured search comment. Single indexed lookup; safe to call from the UI
+ /// thread. Returns null when no observed_player row exists for the id.
Task GetDetailAsync(ulong contentId, CancellationToken ct = default);
}
diff --git a/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs b/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
index 58ea9e7..aa482a9 100644
--- a/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
+++ b/NexusKit.Modules.InternalData/Players/InternalDataPlayerWatcher.cs
@@ -527,6 +527,39 @@ public async Task UpdateNotesAsync(ulong contentId, string? notes, Cancell
return true;
}
+ public async Task SetSearchCommentAsync(
+ ulong contentId, string? searchComment, CancellationToken ct = default)
+ {
+ var normalized = string.IsNullOrWhiteSpace(searchComment) ? null : searchComment.Trim();
+ try
+ {
+ await using var ctx = await mDb.CreateDbContextAsync(ct).ConfigureAwait(false);
+ var row = await ctx.Set()
+ .FindAsync(new object[] { contentId }, ct).ConfigureAwait(false);
+ // No observation row means we never saw this character on the object
+ // table. Nothing to attach the comment to, and inventing a row here
+ // would fabricate a sighting that never happened.
+ if (row is null) return SearchCommentUpdate.NotApplied;
+
+ var previous = row.SearchComment;
+ if (string.Equals(previous, normalized, StringComparison.Ordinal))
+ return SearchCommentUpdate.Unchanged(previous);
+
+ row.SearchComment = normalized;
+ // UpdatedAt deliberately left alone, same as UpdateNotesAsync: it
+ // drives observation-freshness, and examining somebody is not a
+ // sighting of them.
+ await ctx.SaveChangesAsync(ct).ConfigureAwait(false);
+ return SearchCommentUpdate.Changed(previous, normalized);
+ }
+ catch (OperationCanceledException) { return SearchCommentUpdate.NotApplied; }
+ catch (Exception ex)
+ {
+ mLog.LogWarning(ex, "InternalData: failed to persist search comment for ContentId {Cid}", contentId);
+ return SearchCommentUpdate.NotApplied;
+ }
+ }
+
public async Task GetDetailAsync(ulong contentId, CancellationToken ct = default)
{
try
@@ -540,7 +573,7 @@ public async Task UpdateNotesAsync(ulong contentId, string? notes, Cancell
var row = await ctx.Set()
.FindAsync(new object[] { contentId }, ct).ConfigureAwait(false);
if (row is null) return null;
- return new ObservedPlayerDetail(contentId, row.Customize, row.Notes);
+ return new ObservedPlayerDetail(contentId, row.Customize, row.Notes, row.SearchComment);
}
catch (OperationCanceledException) { return null; }
catch (Exception ex)
diff --git a/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs b/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
index 9bc1a17..3d67834 100644
--- a/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
+++ b/NexusKit.Modules.InternalData/Players/ObservedPlayerDetail.cs
@@ -12,7 +12,14 @@ namespace NexusKit.Modules.InternalData.Players;
/// hydrated from a lodestone-only source).
/// User-authored notes text. Null / empty when the user
/// hasn't written anything for this character.
+/// The character's own in-game search comment,
+/// captured the last time the user examined them. Null when never captured or
+/// when they have none set. Sits here rather than on
+/// for the same reason as the notes: it is a per-character free-text field the
+/// list never renders, and the hot record holds every observed row at once.
+/// Not the Lodestone biography.
public sealed record ObservedPlayerDetail(
ulong ContentId,
byte[]? FullCustomize,
- string? Notes);
+ string? Notes,
+ string? SearchComment);
diff --git a/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs b/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs
new file mode 100644
index 0000000..36efc33
--- /dev/null
+++ b/NexusKit.Modules.InternalData/Players/SearchCommentCaptureService.cs
@@ -0,0 +1,79 @@
+using Microsoft.Extensions.Logging;
+using NexusKit.GameData.ObjectTables;
+using NexusKit.Modules.InternalData.History;
+
+namespace NexusKit.Modules.InternalData.Players;
+
+///
+/// Bridges the Examine-time search comment into the observation store and the
+/// change history. Subscribes to ,
+/// persists what came in, and logs a
+/// row when the value actually moved.
+/// The diff lives here rather than in
+/// InternalDataHistoryService.OnObservationProcessed because the search
+/// comment never travels through the observation pipeline — the prev/current
+/// pair in simply does not carry it. The
+/// write already reads the previous value, so it hands it back and this class
+/// records the change without a second query.
+/// Same shape as LiveTagChangeRefreshTrigger: a thin subscriber
+/// that owns one cross-cutting reaction and nothing else.
+///
+public sealed class SearchCommentCaptureService : IDisposable
+{
+ private readonly IInspectSearchCommentWatcher mSource;
+ private readonly IInternalDataPlayerWatcher mWatcher;
+ private readonly IInternalDataHistoryService mHistory;
+ private readonly ILogger mLog;
+ private bool mDisposed;
+
+ public SearchCommentCaptureService(
+ IInspectSearchCommentWatcher source,
+ IInternalDataPlayerWatcher watcher,
+ IInternalDataHistoryService history,
+ ILogger log)
+ {
+ mSource = source;
+ mWatcher = watcher;
+ mHistory = history;
+ mLog = log;
+
+ mSource.SearchCommentReceived += OnSearchCommentReceived;
+ }
+
+ public void Dispose()
+ {
+ if (mDisposed) return;
+ mDisposed = true;
+ mSource.SearchCommentReceived -= OnSearchCommentReceived;
+ }
+
+ private void OnSearchCommentReceived(ulong contentId, string? comment)
+ {
+ // Fires on the framework thread — get off it before touching the DB.
+ _ = Task.Run(() => CaptureAsync(contentId, comment));
+ }
+
+ private async Task CaptureAsync(ulong contentId, string? comment)
+ {
+ try
+ {
+ var result = await mWatcher.SetSearchCommentAsync(contentId, comment).ConfigureAwait(false);
+ if (!result.Applied || !result.ValueChanged) return;
+
+ // A first capture on a character we have never examined before is
+ // indistinguishable from them having just written the comment, so it
+ // is recorded as "set" either way. That is the honest reading: all we
+ // can say is that this is the first value we know of.
+ await mHistory.InsertIfNewAsync(
+ contentId,
+ PlayerHistoryKind.SearchCommentChange,
+ DateTime.UtcNow,
+ result.Previous,
+ result.Current).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ mLog.LogWarning(ex, "InternalData: search-comment capture failed for ContentId {Cid}", contentId);
+ }
+ }
+}
diff --git a/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs b/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
new file mode 100644
index 0000000..d5faad1
--- /dev/null
+++ b/NexusKit.Modules.InternalData/Players/SearchCommentUpdate.cs
@@ -0,0 +1,29 @@
+namespace NexusKit.Modules.InternalData.Players;
+
+///
+/// Outcome of .
+/// Carries the previous value so the caller can log a history row without a
+/// second read — the write already had the old value in hand.
+///
+/// False when nothing was written: no observation row for
+/// this character, or the write failed. Distinct from a no-op write, where the
+/// value simply already matched.
+/// True only when the stored value actually moved.
+/// Re-examining somebody whose comment is unchanged lands here as false, which
+/// is what keeps repeat examines out of the history.
+/// The value on file before the write. Null when there
+/// was none — that is the "search comment set for the first time" case.
+/// The value on file after the write.
+public readonly record struct SearchCommentUpdate(
+ bool Applied,
+ bool ValueChanged,
+ string? Previous,
+ string? Current)
+{
+ public static SearchCommentUpdate NotApplied => new(false, false, null, null);
+
+ public static SearchCommentUpdate Unchanged(string? value) => new(true, false, value, value);
+
+ public static SearchCommentUpdate Changed(string? previous, string? current)
+ => new(true, true, previous, current);
+}
diff --git a/NexusKit.Modules.InternalData/docs/history.md b/NexusKit.Modules.InternalData/docs/history.md
index 9c4c21b..eb68317 100644
--- a/NexusKit.Modules.InternalData/docs/history.md
+++ b/NexusKit.Modules.InternalData/docs/history.md
@@ -15,6 +15,7 @@ diffs `(Previous, Current)` on every upsert.
| 2 | `HomeWorldChange` | `prev.HomeWorld != curr.HomeWorld`. The watcher stores the localised name, so this works regardless of plugin culture switches as long as both compare in the same locale. |
| 3 | `CustomizeChange` | Race byte (`Customize[0]`) or gender byte (`Customize[1]`) differ. Hair / face / colour bytes are intentionally **not** tracked — they'd flood the timeline. Race/gender map to Fantasia-grade changes. |
| 4 | `FreeCompanyChange` | `external_player_profile.free_company_lodestone_id` changes during a Lodestone refresh. Detected in `ExternalDataPlayerService.UpsertProfileAsync`, which awaits an `IExternalDataFreeCompanyService.GetAsync` on the new FC id before invoking the change recorder so the FC catalog row is in cache by the time `HistoryNotificationProducer` resolves it for the chat line (the refresh queue's per-category interleave would otherwise leave the catalog row missing for ≥1 minute). The live object-table tag is never used as a signal here. |
+| 5 | `SearchCommentChange` | The character's in-game search comment differs from the stored one. **Not** an observation diff — the search comment is not on the game's `Character` struct and never reaches `ObservationProcessed`. Written by `SearchCommentCaptureService`, which listens to `IInspectSearchCommentWatcher` (a hook on `AgentInspect.ReceiveSearchComment`) and therefore only ever sees characters the user examined. `OldValue` / `NewValue` carry the raw text; either may be null, which is how "set for the first time" and "cleared" are expressed. Not the Lodestone biography. |
Each row stores pre-formatted display strings in `OldValue` / `NewValue`
(e.g. `"Hyur · Female"` for a customize change) so the UI doesn't have to
diff --git a/NexusKit.Modules.InternalData/packages.lock.json b/NexusKit.Modules.InternalData/packages.lock.json
index 3fa0434..b826ed3 100644
--- a/NexusKit.Modules.InternalData/packages.lock.json
+++ b/NexusKit.Modules.InternalData/packages.lock.json
@@ -198,6 +198,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},
diff --git a/NexusKit.Modules.PlayerEnrichment/packages.lock.json b/NexusKit.Modules.PlayerEnrichment/packages.lock.json
index 0e368f1..ab45fd5 100644
--- a/NexusKit.Modules.PlayerEnrichment/packages.lock.json
+++ b/NexusKit.Modules.PlayerEnrichment/packages.lock.json
@@ -260,6 +260,7 @@
"nexuskit.gamedata": {
"type": "Project",
"dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )",
"NexusKit.Core": "[1.0.0, )"
}
},