@audiofool/moondog 0.2.0-beta.2 → 0.2.0-beta.4
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 +5 -2
- package/package.json +1 -1
- package/src/core/apple-projection-domain-services.mjs +95 -27
- package/src/core/capability-catalog.mjs +12 -0
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-profile-domain-services.mjs +44 -8
- package/src/core/moondog-application.mjs +238 -34
- package/src/core/spotify-listening-intent.mjs +5 -2
- package/src/core/track-title.mjs +107 -0
- package/src/i18n/messages/de.mjs +7 -0
- package/src/i18n/messages/en.mjs +7 -0
- package/src/i18n/messages/es.mjs +7 -0
- package/src/i18n/messages/fr.mjs +7 -0
- package/src/i18n/messages/it.mjs +7 -0
- package/src/i18n/messages/ja.mjs +7 -0
- package/src/i18n/messages/ko.mjs +7 -0
- package/src/i18n/messages/pt.mjs +7 -0
- package/src/i18n/messages/zh-Hant.mjs +7 -0
- package/src/i18n/messages/zh.mjs +7 -0
- package/src/i18n/screen/es.mjs +1 -0
- package/src/i18n/screen/ja.mjs +1 -0
- package/src/i18n/screen/pt.mjs +1 -0
- package/src/i18n/screen/zh.mjs +1 -0
- package/src/integrations/apple-music/catalog.mjs +52 -9
- package/src/integrations/netease/ncm-cli-player.mjs +10 -3
- package/src/integrations/spotify/catalog-resolver.mjs +1 -2
- package/src/integrations/spotify/web-api-client.mjs +3 -0
- package/src/memory/local-memory-store.mjs +73 -16
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +92 -9
- package/src/profile/listener-profile-worker.mjs +7 -0
- package/src/profile/listening-history-store.mjs +231 -24
- package/src/profile/listening-profile-projection.mjs +212 -10
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/profile-exploration.mjs +16 -8
- package/src/runtime/pi/agent-runtime.mjs +363 -118
- package/src/runtime/pi/profile-build-runtime.mjs +2 -1
- package/src/runtime/pi/skills/listener-profile/SKILL.md +2 -0
- package/src/surfaces/cli/terminal-art.mjs +38 -16
- package/src/surfaces/cli/web-command.mjs +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ARTIST_CREDIT_SEPARATORS as artistSeparators } from "../../core/listener-avoids.mjs";
|
|
1
2
|
import { spotifyResolutionCacheKey } from "./resolution-cache.mjs";
|
|
2
3
|
|
|
3
4
|
export const SPOTIFY_CATALOG_RESOLVER_LIMITS = Object.freeze({
|
|
@@ -10,8 +11,6 @@ export const SPOTIFY_CATALOG_RESOLVER_LIMITS = Object.freeze({
|
|
|
10
11
|
durationToleranceMs: 3_000,
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
const artistSeparators =
|
|
14
|
-
/\s*(?:,|;|&|\/|(?:featuring|feat\.?|ft\.?|with)(?=\s|$)|、|和|与)\s*/iu;
|
|
15
14
|
const bracketedDescriptorPattern = /[((【\[]([^))】\]]+)[))】\]]/gu;
|
|
16
15
|
const trailingDescriptorPattern = /^(.*)\s+(?:-|–)\s+(.+)$/u;
|
|
17
16
|
const materialRecordingTags = new Set([
|
|
@@ -330,6 +330,9 @@ function normalizeTrackSearchItem(raw) {
|
|
|
330
330
|
const popularity = safeInteger(raw.popularity, 0, 100);
|
|
331
331
|
if (popularity !== undefined) item.popularity = popularity;
|
|
332
332
|
if (typeof raw.explicit === "boolean") item.explicit = raw.explicit;
|
|
333
|
+
// Host-only, like the URI: it tells two copies of one recording apart from two songs.
|
|
334
|
+
const isrc = safeText(raw.external_ids?.isrc, 32);
|
|
335
|
+
if (isrc) item.isrc = isrc.toUpperCase();
|
|
333
336
|
return item;
|
|
334
337
|
}
|
|
335
338
|
|
|
@@ -572,8 +572,31 @@ function normalized(value) {
|
|
|
572
572
|
return value.normalize("NFKC").toLocaleLowerCase("en-US");
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
// Memories the listener saved themselves: with remember, /remember or the desktop app, or by
|
|
576
|
+
// asking Moondog in conversation, where the host checks the words against their message.
|
|
577
|
+
const listenerSavedOrigins = ["explicit_user", "agent_from_explicit_user_statement"];
|
|
578
|
+
|
|
579
|
+
// Text cut to `maximum` characters, ending in an ellipsis when it was cut.
|
|
580
|
+
function clipped(value, maximum) {
|
|
581
|
+
const characters = Array.from(value);
|
|
582
|
+
return characters.length > maximum ? `${characters.slice(0, maximum).join("").trimEnd()}…` : value;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Common English words that would recall unrelated memories on their own.
|
|
586
|
+
const stopwords = new Set(`about after again all also am an and any are as at be been but by can could
|
|
587
|
+
did do does don for from get had has have he her here him his how if in into is it its just let like
|
|
588
|
+
me more most my no not now of on one or our out please really she so some than that the their them
|
|
589
|
+
then there these they this those to too up us very was we were what when where which who why will
|
|
590
|
+
with would you your`.split(/\s+/u));
|
|
591
|
+
const cjkScript = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
592
|
+
const cjkScripts = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
|
|
593
|
+
// A word without a plural "s", so "record" and "records" find each other.
|
|
594
|
+
const singular = (word) => (word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word);
|
|
595
|
+
|
|
596
|
+
// Search terms in a query: Han bigrams, and whole words elsewhere. No query gives null, which
|
|
597
|
+
// matches everything; a query of only common words gives no terms, which matches nothing.
|
|
575
598
|
function queryTerms(value) {
|
|
576
|
-
if (typeof value !== "string") return
|
|
599
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
577
600
|
const text = normalized(value);
|
|
578
601
|
const segments = text.match(/\p{Script=Han}+|[\p{L}\p{N}]+/gu) ?? [];
|
|
579
602
|
const terms = [];
|
|
@@ -584,18 +607,21 @@ function queryTerms(value) {
|
|
|
584
607
|
for (let index = 0; index < characters.length - 1; index += 1) {
|
|
585
608
|
terms.push(`${characters[index]}${characters[index + 1]}`);
|
|
586
609
|
}
|
|
587
|
-
} else if (Array.from(segment).length >= 2) {
|
|
588
|
-
terms.push(segment);
|
|
610
|
+
} else if (Array.from(segment).length >= 2 && !stopwords.has(segment)) {
|
|
611
|
+
terms.push(cjkScript.test(segment) ? segment : singular(segment));
|
|
589
612
|
}
|
|
590
613
|
}
|
|
591
614
|
return [...new Set(terms)].slice(0, 24);
|
|
592
615
|
}
|
|
593
616
|
|
|
617
|
+
// CJK terms match inside the text, since those scripts do not space words; other terms match
|
|
618
|
+
// whole words only, plural or not, so "me" no longer recalls "named".
|
|
594
619
|
function relevanceScore(memory, terms) {
|
|
595
|
-
if (terms
|
|
620
|
+
if (!terms?.length) return 0;
|
|
596
621
|
const content = normalized(memory.content);
|
|
622
|
+
const words = new Set((content.replace(cjkScripts, " ").match(/[\p{L}\p{N}]+/gu) ?? []).map(singular));
|
|
597
623
|
return terms.reduce(
|
|
598
|
-
(score, term) => score + (content.includes(term) ? term.length : 0),
|
|
624
|
+
(score, term) => score + ((cjkScript.test(term) ? content.includes(term) : words.has(term)) ? term.length : 0),
|
|
599
625
|
0,
|
|
600
626
|
);
|
|
601
627
|
}
|
|
@@ -724,6 +750,16 @@ export class LocalMemoryStore {
|
|
|
724
750
|
timestamp,
|
|
725
751
|
timestamp,
|
|
726
752
|
);
|
|
753
|
+
// Saving again what the memory agent drew from talk makes it the listener's own.
|
|
754
|
+
if (inserted.changes === 0 && listenerSavedOrigins.includes(mutation.origin)) {
|
|
755
|
+
this.#database
|
|
756
|
+
.prepare(`
|
|
757
|
+
UPDATE memory_claims SET origin = ?
|
|
758
|
+
WHERE status = 'active' AND normalized_content = ?
|
|
759
|
+
AND origin NOT IN (${listenerSavedOrigins.map(() => "?").join(", ")})
|
|
760
|
+
`)
|
|
761
|
+
.run(mutation.origin, mutation.normalizedContent, ...listenerSavedOrigins);
|
|
762
|
+
}
|
|
727
763
|
const row = this.#database
|
|
728
764
|
.prepare(`
|
|
729
765
|
SELECT *
|
|
@@ -1928,7 +1964,7 @@ export class LocalMemoryStore {
|
|
|
1928
1964
|
const terms = queryTerms(query);
|
|
1929
1965
|
const ranked = rows
|
|
1930
1966
|
.map((row) => ({ row, score: relevanceScore(row, terms) }))
|
|
1931
|
-
.filter(({ score }) => terms
|
|
1967
|
+
.filter(({ score }) => terms === null || score > 0)
|
|
1932
1968
|
.sort(
|
|
1933
1969
|
(left, right) =>
|
|
1934
1970
|
right.score - left.score ||
|
|
@@ -1978,7 +2014,7 @@ export class LocalMemoryStore {
|
|
|
1978
2014
|
score: relevanceScore({ content: row.summary }, terms),
|
|
1979
2015
|
}))
|
|
1980
2016
|
.filter(
|
|
1981
|
-
({ score }) => terms
|
|
2017
|
+
({ score }) => terms === null || score > 0,
|
|
1982
2018
|
)
|
|
1983
2019
|
.sort(
|
|
1984
2020
|
(left, right) =>
|
|
@@ -2030,29 +2066,50 @@ export class LocalMemoryStore {
|
|
|
2030
2066
|
}));
|
|
2031
2067
|
}
|
|
2032
2068
|
|
|
2069
|
+
// What the agent reads on every model request, so it is kept short: no ids it cannot use,
|
|
2070
|
+
// and earlier turns clipped.
|
|
2033
2071
|
context({ query, currentSessionId } = {}) {
|
|
2034
2072
|
this.#assertOpen();
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2073
|
+
// Facts the memory agent drew from conversations come back only when a request is about
|
|
2074
|
+
// them. A fact the listener saved themselves is something they want kept in mind, so it
|
|
2075
|
+
// stays in view, in slots of its own so saved notes never push out a preference or a rule.
|
|
2076
|
+
const latest = (filter, ...values) => this.#database.prepare(`SELECT * FROM memory_claims
|
|
2077
|
+
WHERE status = 'active' AND ${filter} ORDER BY updated_at DESC, memory_id ASC LIMIT ?`).all(...values);
|
|
2078
|
+
const durableMemories = [
|
|
2079
|
+
...latest("kind != 'fact'", 6),
|
|
2080
|
+
...latest(`kind = 'fact' AND origin IN (${listenerSavedOrigins.map(() => "?").join(", ")})`, ...listenerSavedOrigins, 4),
|
|
2081
|
+
].map(publicMemory);
|
|
2038
2082
|
const durableIds = new Set(
|
|
2039
2083
|
durableMemories.map((memory) => memory.memory_id),
|
|
2040
2084
|
);
|
|
2085
|
+
const memory = ({ memory_id, kind, horizon, text, created_at }) => ({ memory_id, kind, horizon, text, created_at });
|
|
2041
2086
|
return {
|
|
2042
|
-
durable_memories: durableMemories,
|
|
2043
|
-
relevant_memories: this.listMemories({ limit: 8, query })
|
|
2044
|
-
(
|
|
2045
|
-
|
|
2087
|
+
durable_memories: durableMemories.map(memory),
|
|
2088
|
+
relevant_memories: this.listMemories({ limit: 8, query })
|
|
2089
|
+
.filter((candidate) => !durableIds.has(candidate.memory_id))
|
|
2090
|
+
.map(memory),
|
|
2046
2091
|
recent_episodes: this.recentEpisodes({
|
|
2047
2092
|
query,
|
|
2048
2093
|
excludeSessionId: currentSessionId,
|
|
2049
2094
|
limit: 10,
|
|
2050
|
-
})
|
|
2095
|
+
}).map((episode) => ({
|
|
2096
|
+
kind: episode.kind,
|
|
2097
|
+
summary: clipped(episode.summary, 400),
|
|
2098
|
+
occurred_at: episode.occurred_at,
|
|
2099
|
+
...(episode.expires_at ? { expires_at: episode.expires_at } : {}),
|
|
2100
|
+
})),
|
|
2051
2101
|
recent_sessions: this.recentSessions({
|
|
2052
2102
|
excludeSessionId: currentSessionId,
|
|
2053
2103
|
limit: 2,
|
|
2054
2104
|
turnsPerSession: 6,
|
|
2055
|
-
})
|
|
2105
|
+
}).map((session) => ({
|
|
2106
|
+
started_at: session.started_at,
|
|
2107
|
+
ended_at: session.ended_at,
|
|
2108
|
+
turns: session.turns.map((turn) => ({
|
|
2109
|
+
...turn,
|
|
2110
|
+
text: clipped(turn.text, turn.role === "assistant" ? 600 : 400),
|
|
2111
|
+
})),
|
|
2112
|
+
})),
|
|
2056
2113
|
};
|
|
2057
2114
|
}
|
|
2058
2115
|
|
|
@@ -194,6 +194,28 @@ export function createListenerCorrectionRetraction({
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Records a Like or Avoid the way the profile command does, or retracts the active choice for
|
|
199
|
+
* the same artist or track. A retraction with no such choice changes nothing.
|
|
200
|
+
*/
|
|
201
|
+
export function recordListenerChoice(store, {
|
|
202
|
+
subjectId, entityType, label, artistCredit, stance, occurredAt = new Date().toISOString(),
|
|
203
|
+
} = {}) {
|
|
204
|
+
if (stance !== "retract") {
|
|
205
|
+
return store.recordListenerCorrection({ subjectId, entityType, label, artistCredit, stance, occurredAt, recordedAt: occurredAt });
|
|
206
|
+
}
|
|
207
|
+
const key = (value) => typeof value === "string" ? normalizedIdentity(value.replace(/\s+/gu, " ").trim()) : "";
|
|
208
|
+
const active = store.listListenerCorrections({ subjectId, limit: 100 }).find((choice) =>
|
|
209
|
+
choice.entity_type === entityType && key(choice.label) === key(label) &&
|
|
210
|
+
(entityType !== "track" || key(choice.artist_credit) === key(artistCredit)));
|
|
211
|
+
if (!active) return { state: "not_found", entity_type: entityType, label };
|
|
212
|
+
return {
|
|
213
|
+
...store.retractListenerCorrection({ subjectId, correctionId: active.correction_id, occurredAt, recordedAt: occurredAt }),
|
|
214
|
+
stance: active.stance,
|
|
215
|
+
...(active.artist_credit ? { artist_credit: active.artist_credit } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
197
219
|
export function listenerCorrectionTargetKey(record) {
|
|
198
220
|
if (
|
|
199
221
|
!record ||
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
|
+
import { avoidKey } from "../core/listener-avoids.mjs";
|
|
3
4
|
import { recordedShare } from "./recorded-measures.mjs";
|
|
4
5
|
|
|
5
6
|
export const LISTENER_PROFILE_ANALYSIS_VERSION = "listener-profile/2";
|
|
6
|
-
export const LISTENER_PROFILE_SYNTHESIS_VERSION = "listener-synthesis/
|
|
7
|
+
export const LISTENER_PROFILE_SYNTHESIS_VERSION = "listener-synthesis/5";
|
|
7
8
|
// A saved reading is current only after its whole-profile check has run.
|
|
8
9
|
export const CHECKED_PROFILE_STATES = new Set(["passed", "revised", "flagged"]);
|
|
9
10
|
|
|
@@ -74,6 +75,10 @@ function sectionSemantics(section, { time = "all" } = {}) {
|
|
|
74
75
|
: "Not a preference ranking; listening time is not known for every play.",
|
|
75
76
|
limitations: "Row order is not save or addition chronology. Missing listening minutes mean no matched duration in this import, not proof the listener never played a saved track.",
|
|
76
77
|
};
|
|
78
|
+
if (section === "moondog_suggestions") return {
|
|
79
|
+
ordering: "Plays descending, then most recent.",
|
|
80
|
+
limitations: "Moondog queued or played these, so they are left out of every listening ranking and are not the listener's taste. adopted means the listener later played the song again on their own, which is a reaction to a suggestion, not a preference by itself.",
|
|
81
|
+
};
|
|
77
82
|
if (section === "playlist_tracks") return { ...common,
|
|
78
83
|
ordering: "Playlist membership count descending, then measured listening duration descending, then title." };
|
|
79
84
|
if (section.startsWith("apple_")) return {
|
|
@@ -150,14 +155,16 @@ export function modelEvidence(entry, offset = 0, limit = 12) {
|
|
|
150
155
|
"explicit_skips", "listening_minutes", "distinct_tracks", "year", "event_count", "first_observed_tracks",
|
|
151
156
|
"playlist_count", "library_tracks", "preferred_tracks", "tracks_with_play_count", "preference_strength",
|
|
152
157
|
"loved", "favorited", "rating_value", "rating_computed", "rank", "playlist_position", "value",
|
|
153
|
-
"stream_count", "played_seconds", "playlist_names", "top_artist", "first_year", "last_year", "active_years",
|
|
158
|
+
"stream_count", "played_seconds", "playlist_names", "top_artist", "first_year", "last_year", "active_years", "adopted",
|
|
159
|
+
"yearly_plays", "activation", "trajectory",
|
|
154
160
|
]);
|
|
155
161
|
const data = Object.fromEntries(Object.entries(entry.data).filter(([key]) => fields.has(key)));
|
|
156
162
|
if (data.top_artist) data.top_artist = Object.fromEntries(Object.entries(data.top_artist)
|
|
157
163
|
.filter(([key]) => ["name", "play_count", "listening_minutes"].includes(key)));
|
|
158
164
|
const continuations = {};
|
|
159
165
|
for (const [key, value] of Object.entries(data)) {
|
|
160
|
-
|
|
166
|
+
// A yearly series is already capped, and a page of it would hide the latest years.
|
|
167
|
+
if (Array.isArray(value) && key !== "yearly_plays") {
|
|
161
168
|
data[key] = value.slice(offset, offset + limit);
|
|
162
169
|
if (value.length > offset + limit) continuations[key] = { total: value.length, next_offset: offset + limit };
|
|
163
170
|
}
|
|
@@ -218,7 +225,16 @@ export function listenerProfileDossier(input) {
|
|
|
218
225
|
sections,
|
|
219
226
|
lifetime_artists: lifetime.slice(0, 40).map(row),
|
|
220
227
|
next_lifetime_artists: lifetime.slice(40, 80).map(entry => ({ ref: entry.reference_id, name: entry.data.name,
|
|
221
|
-
listening_minutes: entry.data.listening_minutes, play_count: entry.data.play_count, distinct_tracks: entry.data.distinct_tracks
|
|
228
|
+
listening_minutes: entry.data.listening_minutes, play_count: entry.data.play_count, distinct_tracks: entry.data.distinct_tracks,
|
|
229
|
+
trajectory: entry.data.trajectory })),
|
|
230
|
+
movement: {
|
|
231
|
+
note: "Trajectory labels are computed from each artist's share of yearly plays: fading and rising compare the latest counted year with the years before it, returning means whole years without plays, or a decline, and then plays again, burst means one earlier year holds most of the plays and the next year drops off at once. They describe attention over time, not liking.",
|
|
232
|
+
...Object.fromEntries(["fading", "returning", "rising", "burst"].map(label => [label, lifetime
|
|
233
|
+
.filter(entry => entry.data.trajectory === label).slice(0, 12)
|
|
234
|
+
.map(entry => ({ ref: entry.reference_id, name: entry.data.name, play_count: entry.data.play_count,
|
|
235
|
+
first_played_at: entry.data.first_played_at, last_played_at: entry.data.last_played_at,
|
|
236
|
+
yearly_plays: entry.data.yearly_plays }))])),
|
|
237
|
+
},
|
|
222
238
|
recent_artists: rows("recent_artists").slice(0, 25).map(entry => ({ ...row(entry),
|
|
223
239
|
lifetime_rank: lifetimeRank.get(artistKey(entry.data.name)) ?? null })),
|
|
224
240
|
years: take("history_years", 64),
|
|
@@ -243,6 +259,13 @@ export function listenerProfileDossier(input) {
|
|
|
243
259
|
},
|
|
244
260
|
provider_signals: provider.slice(0, 50).map(row),
|
|
245
261
|
explicit_choices: [...rows("listener_preferences"), ...rows("listener_avoids")].slice(0, 100).map(row),
|
|
262
|
+
...(rows("moondog_suggestions").length ? { played_from_moondog: {
|
|
263
|
+
note: "Plays Moondog itself queued or played. They are not in any ranking above and are not taste. adopted means the listener played the song again later on their own.",
|
|
264
|
+
plays: input.manifest.context.moondog_started_events_excluded ?? null,
|
|
265
|
+
tracks: rows("moondog_suggestions").length,
|
|
266
|
+
adopted_tracks: rows("moondog_suggestions").filter(entry => entry.data.adopted === true).length,
|
|
267
|
+
examples: take("moondog_suggestions", 8),
|
|
268
|
+
} } : {}),
|
|
246
269
|
};
|
|
247
270
|
}
|
|
248
271
|
|
|
@@ -311,14 +334,62 @@ export function sameListenerChoices(evidence, otherEvidence) {
|
|
|
311
334
|
return choices(evidence) === choices(otherEvidence);
|
|
312
335
|
}
|
|
313
336
|
|
|
314
|
-
|
|
337
|
+
const CHOICES_SHOWN = 30;
|
|
338
|
+
|
|
339
|
+
// A choice as the model reads it: no correction or evidence IDs.
|
|
340
|
+
function choiceItems(evidence) {
|
|
341
|
+
return [...evidence.values()].filter(entry => LISTENER_SECTIONS.has(entry.section)).map(({ section, data }) => ({
|
|
342
|
+
key: [section, data.entity_type, avoidKey(data.label ?? data.name), avoidKey(data.artist_credit)].join("\0"),
|
|
343
|
+
choice: {
|
|
344
|
+
stance: section === "listener_avoids" ? "avoid" : "like",
|
|
345
|
+
subject: data.entity_type ?? "artist", name: data.label ?? data.name,
|
|
346
|
+
...(data.artist_credit ? { artist: data.artist_credit } : {}),
|
|
347
|
+
...(data.asserted_at ? { chosen_at: data.asserted_at } : {}),
|
|
348
|
+
...(data.correction_id ? {} : { from_import: true }),
|
|
349
|
+
},
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The listener's choices now, newest first, and how they differ from the ones a reading was built with.
|
|
354
|
+
function listenerChoices(current, previous) {
|
|
355
|
+
const now = choiceItems(current);
|
|
356
|
+
const then = choiceItems(previous);
|
|
357
|
+
const direct = now.filter(item => !item.choice.from_import)
|
|
358
|
+
.sort((left, right) => (right.choice.chosen_at ?? "").localeCompare(left.choice.chosen_at ?? ""));
|
|
359
|
+
const missing = (items, others) => {
|
|
360
|
+
const keys = new Set(others.map(item => item.key));
|
|
361
|
+
return items.filter(item => !keys.has(item.key)).slice(0, CHOICES_SHOWN).map(item => item.choice);
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
listener_choices: { total: direct.length, items: direct.slice(0, CHOICES_SHOWN).map(item => item.choice) },
|
|
365
|
+
choices_since_reading: { added: missing(now, then), retracted: missing(then, now) },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The listener's current choices read straight from their saved corrections, newest first, for
|
|
370
|
+
// when there is no saved reading to compare them with.
|
|
371
|
+
export function savedListenerChoices(corrections, total) {
|
|
372
|
+
return {
|
|
373
|
+
total,
|
|
374
|
+
items: corrections.slice(0, CHOICES_SHOWN).map(choice => ({
|
|
375
|
+
stance: choice.stance, subject: choice.entity_type, name: choice.label,
|
|
376
|
+
...(choice.artist_credit ? { artist: choice.artist_credit } : {}),
|
|
377
|
+
...(choice.occurred_at ? { chosen_at: choice.occurred_at } : {}),
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function readListenerProfile({ input, getInput, store, getChoices }, { offset = 0, limit = 12, claimId, revisionId } = {}) {
|
|
315
383
|
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 20) {
|
|
316
384
|
throw new TypeError("Saved profile page is invalid");
|
|
317
385
|
}
|
|
318
386
|
const current = store.current();
|
|
319
387
|
const revision = revisionId ? store.revision(revisionId) : current;
|
|
320
388
|
if (revisionId && !revision) throw new Error("That saved profile version is unavailable");
|
|
321
|
-
if (!revision)
|
|
389
|
+
if (!revision) {
|
|
390
|
+
const choices = getChoices?.();
|
|
391
|
+
return { state: "missing", total_claims: 0, claims: [], next_offset: null, ...(choices ? { listener_choices: choices } : {}) };
|
|
392
|
+
}
|
|
322
393
|
// Most new listeners have no saved reading. Do not scan their source archive
|
|
323
394
|
// merely to tell an ordinary conversation that the reading is missing.
|
|
324
395
|
input ??= getInput();
|
|
@@ -345,6 +416,7 @@ export function readListenerProfile({ input, getInput, store }, { offset = 0, li
|
|
|
345
416
|
}),
|
|
346
417
|
listener_assertions: revision.claims.flatMap((claim, index) => claim.kind === "listener_assertion"
|
|
347
418
|
? [{ ...claim, finding_number: index + 1 }] : []),
|
|
419
|
+
...listenerChoices(input.evidence ?? new Map(), new Map(Object.entries(revision.evidence ?? {}))),
|
|
348
420
|
};
|
|
349
421
|
if (claimId !== undefined) {
|
|
350
422
|
const claim = revision.claims.find(item => item.claim_id === claimId);
|
|
@@ -357,22 +429,33 @@ export function readListenerProfile({ input, getInput, store }, { offset = 0, li
|
|
|
357
429
|
}
|
|
358
430
|
|
|
359
431
|
export function compactListenerProfile(value) {
|
|
360
|
-
|
|
432
|
+
// With no reading, the listener's own choices still hold.
|
|
433
|
+
if (!value || value.state === "missing") {
|
|
434
|
+
return { state: "missing", ...(value?.listener_choices?.total ? { listener_choices: value.listener_choices } : {}) };
|
|
435
|
+
}
|
|
361
436
|
const coverage = value.coverage?.coverage ?? {};
|
|
362
437
|
const flagged = value.verification?.state === "flagged"
|
|
363
438
|
? value.verification.checks.at(-1).issues.slice(0, 3).map(issue => issue.problem) : [];
|
|
364
439
|
const behind = value.state === "stale" && value.newer_listening_only === true;
|
|
440
|
+
// A stale reading still shows what it said, marked as the previous one, under the current choices.
|
|
441
|
+
const previous = value.state === "stale" && !behind && typeof value.summary === "string";
|
|
442
|
+
const since = value.choices_since_reading;
|
|
365
443
|
return {
|
|
366
444
|
state: behind ? "current_through" : value.state, revision_id: value.revision_id, sequence: value.sequence, built_at: value.built_at,
|
|
367
|
-
...(value.state === "current" || behind ? {
|
|
445
|
+
...(value.state === "current" || behind || previous ? {
|
|
368
446
|
...(behind ? { note: "Built from listening up to coverage.last_played_at. Plays stored since then are not in this reading; listening coverage shows them. Listener choices and analysis are unchanged." } : {}),
|
|
447
|
+
...(previous ? { previous_reading: true,
|
|
448
|
+
note: "The previous reading. It predates listener_choices and choices_since_reading, or the analysis has changed since. Use it only as background: where they differ, the listener's choices win. Update the reading before treating it as current taste." } : {}),
|
|
369
449
|
summary: value.summary,
|
|
370
450
|
claims: value.highlights,
|
|
371
|
-
|
|
451
|
+
// A previous reading's own choices may be out of date; listener_choices holds the current ones.
|
|
452
|
+
...(previous ? {} : { listener_assertions: value.listener_assertions }),
|
|
372
453
|
coverage: { listening_events: coverage.effective_listening_events ?? null,
|
|
373
454
|
first_played_at: coverage.earliest_played_at ?? null, last_played_at: coverage.latest_played_at ?? null,
|
|
374
455
|
library_tracks: coverage.apple_library_tracks ?? null },
|
|
375
456
|
...(flagged.length ? { unresolved_check_notes: flagged } : {}),
|
|
376
457
|
} : { message: "Source evidence, listener choices or analysis changed. Update the reading before using the previous interpretation as current taste." }),
|
|
458
|
+
...(value.listener_choices ? { listener_choices: value.listener_choices } : {}),
|
|
459
|
+
...(since?.added?.length || since?.retracted?.length ? { choices_since_reading: since } : {}),
|
|
377
460
|
};
|
|
378
461
|
}
|
|
@@ -18,12 +18,19 @@ const HOUR_MS = 3_600_000;
|
|
|
18
18
|
// or this one, holds off the next background attempt for half a day.
|
|
19
19
|
export const PROFILE_REFRESH = { listeningAgeMs: 7 * 24 * HOUR_MS, newPlays: 300, attemptGapMs: 12 * HOUR_MS };
|
|
20
20
|
|
|
21
|
+
const synthesisNumber = (version) => Number(/^listener-synthesis\/(\d+)$/u.exec(version ?? "")?.[1] ?? Number.NaN);
|
|
22
|
+
|
|
21
23
|
/** Rebuild a saved reading that newer evidence has overtaken, without being asked. */
|
|
22
24
|
export async function refreshListenerProfileIfDue({ application, now = Date.now(), ...options } = {}) {
|
|
23
25
|
// The first reading is the listener's to ask for.
|
|
24
26
|
if (!application.profileBuildReady()) return { state: "unavailable" };
|
|
25
27
|
const reading = await application.getListenerProfile({ limit: 1 });
|
|
26
28
|
if (reading.state !== "stale") return { state: reading.state };
|
|
29
|
+
// A reading a newer Moondog wrote is left to that version or to the listener's own build, so an
|
|
30
|
+
// older terminal and a newer app never take turns rebuilding over each other's reading.
|
|
31
|
+
if (synthesisNumber(reading.synthesis_version) > synthesisNumber(LISTENER_PROFILE_SYNTHESIS_VERSION)) {
|
|
32
|
+
return { state: "newer_version" };
|
|
33
|
+
}
|
|
27
34
|
const { input, store } = application.getProfileBuildContext();
|
|
28
35
|
const lastAttempt = Date.parse(store.lastAttemptAt() ?? "");
|
|
29
36
|
if (now - lastAttempt < PROFILE_REFRESH.attemptGapMs) return { state: "waiting" };
|