@audiofool/moondog 0.1.0 → 0.2.0-beta.3
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/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { profileDigest } from "./listener-profile-build.mjs";
|
|
3
|
+
|
|
4
|
+
// listener_profile_partitions holds page findings from builds before
|
|
5
|
+
// listener-synthesis/4. New builds no longer write it; it stays so existing
|
|
6
|
+
// databases open unchanged.
|
|
7
|
+
export const listenerProfileSchema = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS listener_profile_builds (
|
|
9
|
+
build_id TEXT PRIMARY KEY NOT NULL, subject_id TEXT NOT NULL,
|
|
10
|
+
input_digest TEXT NOT NULL, model_key TEXT NOT NULL,
|
|
11
|
+
lease_token TEXT NOT NULL, state TEXT NOT NULL, record_json TEXT NOT NULL
|
|
12
|
+
) STRICT;
|
|
13
|
+
CREATE INDEX IF NOT EXISTS listener_profile_build_input
|
|
14
|
+
ON listener_profile_builds(subject_id, input_digest, model_key);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS listener_profile_partitions (
|
|
16
|
+
build_id TEXT NOT NULL, partition_id TEXT NOT NULL,
|
|
17
|
+
review_json TEXT NOT NULL,
|
|
18
|
+
PRIMARY KEY (build_id, partition_id),
|
|
19
|
+
FOREIGN KEY (build_id) REFERENCES listener_profile_builds(build_id)
|
|
20
|
+
) STRICT;
|
|
21
|
+
CREATE INDEX IF NOT EXISTS listener_profile_partition_cache ON listener_profile_partitions(partition_id);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS listener_profile_revisions (
|
|
23
|
+
revision_id TEXT PRIMARY KEY NOT NULL, subject_id TEXT NOT NULL,
|
|
24
|
+
sequence INTEGER NOT NULL, input_digest TEXT NOT NULL, record_json TEXT NOT NULL,
|
|
25
|
+
UNIQUE (subject_id, sequence)
|
|
26
|
+
) STRICT;
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export class ListenerProfileStore {
|
|
30
|
+
constructor(database, subjectId) {
|
|
31
|
+
this.database = database;
|
|
32
|
+
this.subjectId = subjectId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
current() {
|
|
36
|
+
const row = this.database.prepare(`SELECT record_json FROM listener_profile_revisions
|
|
37
|
+
WHERE subject_id = ? ORDER BY sequence DESC LIMIT 1`).get(this.subjectId);
|
|
38
|
+
return row ? JSON.parse(row.record_json) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
revision(id) {
|
|
42
|
+
const row = this.database.prepare(`SELECT record_json FROM listener_profile_revisions
|
|
43
|
+
WHERE subject_id = ? AND revision_id = ?`).get(this.subjectId, id);
|
|
44
|
+
return row ? JSON.parse(row.record_json) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** When a build last started or resumed, whether the listener asked for it or not. */
|
|
48
|
+
lastAttemptAt() {
|
|
49
|
+
return this.database.prepare(`SELECT MAX(COALESCE(json_extract(record_json, '$.attempted_at'),
|
|
50
|
+
json_extract(record_json, '$.started_at'))) AS attempted_at
|
|
51
|
+
FROM listener_profile_builds WHERE subject_id = ?`).get(this.subjectId)?.attempted_at ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resume an unfinished build for the same input and model, or start a new one. */
|
|
55
|
+
begin(input, model, { force = false } = {}) {
|
|
56
|
+
const modelKey = profileDigest(model);
|
|
57
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
58
|
+
try {
|
|
59
|
+
const parent = this.current()?.revision_id ?? null;
|
|
60
|
+
const existing = force ? null : this.database.prepare(`SELECT record_json FROM listener_profile_builds
|
|
61
|
+
WHERE subject_id = ? AND input_digest = ? AND model_key = ? AND state <> 'complete'
|
|
62
|
+
ORDER BY rowid DESC LIMIT 1`).get(this.subjectId, input.input_digest, modelKey);
|
|
63
|
+
const saved = existing ? JSON.parse(existing.record_json) : null;
|
|
64
|
+
// A checkpoint written against an older current reading cannot replace the newer one.
|
|
65
|
+
const resumable = saved?.parent_revision_id === parent ? saved : null;
|
|
66
|
+
const build = resumable ?? {
|
|
67
|
+
build_id: randomUUID(), input_digest: input.input_digest, model,
|
|
68
|
+
parent_revision_id: parent, started_at: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
build.lease_token = randomUUID();
|
|
71
|
+
build.attempted_at = new Date().toISOString();
|
|
72
|
+
this.database.prepare(`INSERT INTO listener_profile_builds
|
|
73
|
+
(build_id, subject_id, input_digest, model_key, lease_token, state, record_json)
|
|
74
|
+
VALUES (?, ?, ?, ?, ?, 'building', ?) ON CONFLICT(build_id) DO UPDATE
|
|
75
|
+
SET lease_token = excluded.lease_token, state = 'building', record_json = excluded.record_json`)
|
|
76
|
+
.run(build.build_id, this.subjectId, input.input_digest, modelKey, build.lease_token, JSON.stringify(build));
|
|
77
|
+
this.database.exec("COMMIT");
|
|
78
|
+
return { ...build, resumed: Boolean(resumable) };
|
|
79
|
+
} catch (error) { this.database.exec("ROLLBACK"); throw error; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#assertLease(build) {
|
|
83
|
+
const row = this.database.prepare(`SELECT lease_token, state FROM listener_profile_builds
|
|
84
|
+
WHERE subject_id = ? AND build_id = ?`).get(this.subjectId, build.build_id);
|
|
85
|
+
if (row?.lease_token !== build.lease_token || row.state !== "building") {
|
|
86
|
+
throw new Error("Another profile build has taken over; continue from its saved progress");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
checkpointFinalization(build, finalization) {
|
|
91
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
92
|
+
try {
|
|
93
|
+
this.#assertLease(build);
|
|
94
|
+
const row = this.database.prepare("SELECT record_json FROM listener_profile_builds WHERE build_id = ?")
|
|
95
|
+
.get(build.build_id);
|
|
96
|
+
const record = { ...JSON.parse(row.record_json), finalization };
|
|
97
|
+
this.database.prepare("UPDATE listener_profile_builds SET record_json = ? WHERE build_id = ?")
|
|
98
|
+
.run(JSON.stringify(record), build.build_id);
|
|
99
|
+
this.database.exec("COMMIT");
|
|
100
|
+
} catch (error) { this.database.exec("ROLLBACK"); throw error; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interrupt(build) {
|
|
104
|
+
this.database.prepare(`UPDATE listener_profile_builds SET state = 'interrupted'
|
|
105
|
+
WHERE subject_id = ? AND build_id = ? AND lease_token = ? AND state = 'building'`)
|
|
106
|
+
.run(this.subjectId, build.build_id, build.lease_token);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
complete(build, profile, assertCurrentInput = () => {}) {
|
|
110
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
111
|
+
try {
|
|
112
|
+
this.#assertLease(build);
|
|
113
|
+
assertCurrentInput();
|
|
114
|
+
const previous = this.current();
|
|
115
|
+
if ((previous?.revision_id ?? null) !== build.parent_revision_id) {
|
|
116
|
+
throw new Error("The current profile changed during this build; start again from the new version");
|
|
117
|
+
}
|
|
118
|
+
const revision = {
|
|
119
|
+
...profile, revision_id: randomUUID(), sequence: (previous?.sequence ?? 0) + 1,
|
|
120
|
+
parent_revision_id: previous?.revision_id ?? null, built_at: new Date().toISOString(), model: build.model,
|
|
121
|
+
};
|
|
122
|
+
this.database.prepare(`INSERT INTO listener_profile_revisions
|
|
123
|
+
(revision_id, subject_id, sequence, input_digest, record_json) VALUES (?, ?, ?, ?, ?)`)
|
|
124
|
+
.run(revision.revision_id, this.subjectId, revision.sequence, revision.input_digest, JSON.stringify(revision));
|
|
125
|
+
this.database.prepare("UPDATE listener_profile_builds SET state = 'complete' WHERE build_id = ?").run(build.build_id);
|
|
126
|
+
this.database.exec("COMMIT");
|
|
127
|
+
return revision;
|
|
128
|
+
} catch (error) { this.database.exec("ROLLBACK"); throw error; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { modelEvidence, profileDigest } from "./listener-profile-build.mjs";
|
|
2
|
+
|
|
3
|
+
export const LISTENER_PROFILE_CHECK_VERSION = "listener-check/1";
|
|
4
|
+
const MAX_CHECK_ISSUES = 6;
|
|
5
|
+
|
|
6
|
+
export function listenerProfileCandidate(input, profile) {
|
|
7
|
+
const byId = new Map(profile.claims.map(claim => [claim.claim_id, claim]));
|
|
8
|
+
const insights = profile.highlight_claim_ids.map(id => byId.get(id));
|
|
9
|
+
const content = { summary: profile.summary, insights };
|
|
10
|
+
const references = new Set(insights.flatMap(claim => [...claim.supporting_refs, ...claim.contradicting_refs]));
|
|
11
|
+
return {
|
|
12
|
+
candidate_id: profileDigest({ input_digest: input.input_digest, ...content }),
|
|
13
|
+
...content,
|
|
14
|
+
cited_evidence: [...references].map(ref => modelEvidence(input.evidence.get(ref))),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function checkText(value, field) {
|
|
19
|
+
if (typeof value !== "string" || !value.trim() || Array.from(value).length > 500 ||
|
|
20
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f--]/u.test(value)) {
|
|
21
|
+
throw new TypeError(`Each check issue needs a ${field} of at most 500 characters`);
|
|
22
|
+
}
|
|
23
|
+
return value.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One judgment of the whole candidate. Only material problems are issues;
|
|
28
|
+
* citation completeness and phrasing are deliberately out of scope.
|
|
29
|
+
*/
|
|
30
|
+
export function validateListenerProfileCheck(candidate, value) {
|
|
31
|
+
if (value?.candidate_id !== candidate.candidate_id) {
|
|
32
|
+
throw new TypeError("The check must identify the current profile candidate");
|
|
33
|
+
}
|
|
34
|
+
if (!["pass", "revise"].includes(value.verdict)) throw new TypeError("The check verdict must be pass or revise");
|
|
35
|
+
const issues = value.issues ?? [];
|
|
36
|
+
if (!Array.isArray(issues) || issues.length > MAX_CHECK_ISSUES) {
|
|
37
|
+
throw new TypeError(`The check can list at most ${MAX_CHECK_ISSUES} material issues`);
|
|
38
|
+
}
|
|
39
|
+
if (value.verdict === "revise" && !issues.length) throw new TypeError("A revise verdict needs at least one material issue");
|
|
40
|
+
if (value.verdict === "pass" && issues.length) throw new TypeError("A pass verdict lists no issues; return revise for a material problem");
|
|
41
|
+
const targets = new Set(["summary", ...candidate.insights.map(claim => claim.claim_id)]);
|
|
42
|
+
return {
|
|
43
|
+
version: LISTENER_PROFILE_CHECK_VERSION,
|
|
44
|
+
candidate_id: candidate.candidate_id,
|
|
45
|
+
verdict: value.verdict,
|
|
46
|
+
issues: issues.map(issue => {
|
|
47
|
+
if (!targets.has(issue?.target)) {
|
|
48
|
+
throw new TypeError(`Check issue target ${JSON.stringify(issue?.target)} must be "summary" or a claim_id of this candidate`);
|
|
49
|
+
}
|
|
50
|
+
return { target: issue.target, problem: checkText(issue.problem, "problem"), correction: checkText(issue.correction, "correction") };
|
|
51
|
+
}),
|
|
52
|
+
checked_at: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function searchListenerProfileEvidence(input, { section, query, offset = 0 } = {}) {
|
|
57
|
+
if (section !== undefined && !input.manifest.sections.some(item => item.section === section)) {
|
|
58
|
+
throw new TypeError("Unknown evidence section");
|
|
59
|
+
}
|
|
60
|
+
if ((query !== undefined && (typeof query !== "string" || query.length > 200)) ||
|
|
61
|
+
!Number.isSafeInteger(offset) || offset < 0) throw new TypeError("Invalid evidence search");
|
|
62
|
+
const needle = query?.normalize("NFKC").toLocaleLowerCase();
|
|
63
|
+
const matches = [...input.evidence.values()].filter(entry => !section || entry.section === section)
|
|
64
|
+
.filter(entry => !needle || JSON.stringify(modelEvidence(entry, 0, Number.MAX_SAFE_INTEGER).data)
|
|
65
|
+
.normalize("NFKC").toLocaleLowerCase().includes(needle));
|
|
66
|
+
return { total: matches.length, offset, items: matches.slice(offset, offset + 12).map(entry => modelEvidence(entry)),
|
|
67
|
+
next_offset: offset + 12 < matches.length ? offset + 12 : null };
|
|
68
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHECKED_PROFILE_STATES, compileListenerProfile, listenerProfileDossier, LISTENER_PROFILE_SYNTHESIS_VERSION, modelEvidence,
|
|
3
|
+
sameListenerChoices,
|
|
4
|
+
} from "./listener-profile-build.mjs";
|
|
5
|
+
import {
|
|
6
|
+
LISTENER_PROFILE_CHECK_VERSION, listenerProfileCandidate, searchListenerProfileEvidence, validateListenerProfileCheck,
|
|
7
|
+
} from "./listener-profile-verification.mjs";
|
|
8
|
+
import { createConfiguredProfileBuildRuntime } from "../runtime/pi/profile-build-runtime.mjs";
|
|
9
|
+
|
|
10
|
+
// One draft, one check, at most one revision and its check. Spare sessions let
|
|
11
|
+
// a model that hits its turn limit continue the same stage.
|
|
12
|
+
const MAX_REVISIONS = 1;
|
|
13
|
+
const MAX_SESSIONS = 8;
|
|
14
|
+
|
|
15
|
+
const HOUR_MS = 3_600_000;
|
|
16
|
+
// A reading that only new plays have overtaken is rebuilt after a week or 300 plays.
|
|
17
|
+
// Changed choices or analysis rebuild it at once. Any build attempt, the listener's
|
|
18
|
+
// or this one, holds off the next background attempt for half a day.
|
|
19
|
+
export const PROFILE_REFRESH = { listeningAgeMs: 7 * 24 * HOUR_MS, newPlays: 300, attemptGapMs: 12 * HOUR_MS };
|
|
20
|
+
|
|
21
|
+
const synthesisNumber = (version) => Number(/^listener-synthesis\/(\d+)$/u.exec(version ?? "")?.[1] ?? Number.NaN);
|
|
22
|
+
|
|
23
|
+
/** Rebuild a saved reading that newer evidence has overtaken, without being asked. */
|
|
24
|
+
export async function refreshListenerProfileIfDue({ application, now = Date.now(), ...options } = {}) {
|
|
25
|
+
// The first reading is the listener's to ask for.
|
|
26
|
+
if (!application.profileBuildReady()) return { state: "unavailable" };
|
|
27
|
+
const reading = await application.getListenerProfile({ limit: 1 });
|
|
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
|
+
}
|
|
34
|
+
const { input, store } = application.getProfileBuildContext();
|
|
35
|
+
const lastAttempt = Date.parse(store.lastAttemptAt() ?? "");
|
|
36
|
+
if (now - lastAttempt < PROFILE_REFRESH.attemptGapMs) return { state: "waiting" };
|
|
37
|
+
if (reading.newer_listening_only) {
|
|
38
|
+
const newPlays = (input.manifest.coverage?.effective_listening_events ?? 0) -
|
|
39
|
+
(reading.coverage?.coverage?.effective_listening_events ?? 0);
|
|
40
|
+
if (now - Date.parse(reading.built_at) < PROFILE_REFRESH.listeningAgeMs && newPlays < PROFILE_REFRESH.newPlays) {
|
|
41
|
+
return { state: "waiting" };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return runListenerProfileBuild({ ...options, application });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runListenerProfileBuild({
|
|
48
|
+
application, environment = process.env, signal, onProgress = () => {}, force = false,
|
|
49
|
+
runtimeFactory = createConfiguredProfileBuildRuntime,
|
|
50
|
+
} = {}) {
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
const { input, store } = application.getProfileBuildContext();
|
|
53
|
+
if (!input.evidence.size) return { state: "no_evidence" };
|
|
54
|
+
const previous = store.current();
|
|
55
|
+
if (!force && previous?.input_digest === input.input_digest && previous.synthesis_version === LISTENER_PROFILE_SYNTHESIS_VERSION &&
|
|
56
|
+
CHECKED_PROFILE_STATES.has(previous.verification?.state)) {
|
|
57
|
+
return { state: "unchanged", revision: await application.getListenerProfile() };
|
|
58
|
+
}
|
|
59
|
+
const runtime = await runtimeFactory(environment);
|
|
60
|
+
const status = runtime.publicStatus();
|
|
61
|
+
if (status.state !== "configured") throw new Error("Connect a model with /model, then use /profile build. Your imported profile is still available.");
|
|
62
|
+
const model = { provider: status.provider, model: status.model, worker_version: status.worker_version };
|
|
63
|
+
const build = store.begin(input, model, { force });
|
|
64
|
+
// Drafts and checks are checkpoints, never current profile revisions.
|
|
65
|
+
let finalization = build.finalization?.synthesis_version === LISTENER_PROFILE_SYNTHESIS_VERSION
|
|
66
|
+
? structuredClone(build.finalization)
|
|
67
|
+
: { synthesis_version: LISTENER_PROFILE_SYNTHESIS_VERSION, draft: null, checks: [], revisions: 0 };
|
|
68
|
+
let compiled = finalization.draft ? compileListenerProfile(input, finalization.draft) : null;
|
|
69
|
+
let candidate = compiled ? listenerProfileCandidate(input, compiled) : null;
|
|
70
|
+
const latestCheck = () => finalization.checks.at(-1) ?? null;
|
|
71
|
+
const phase = () => !candidate ? "synthesis"
|
|
72
|
+
: latestCheck()?.candidate_id !== candidate.candidate_id ? "check"
|
|
73
|
+
: latestCheck().verdict === "revise" && finalization.revisions < MAX_REVISIONS ? "revision" : "complete";
|
|
74
|
+
const progress = () => ({ phase: phase(), checks: finalization.checks.length, revisions: finalization.revisions,
|
|
75
|
+
evidence_rows: input.evidence.size, resumed: build.resumed });
|
|
76
|
+
const inspected = new Set();
|
|
77
|
+
let stageFinished = false;
|
|
78
|
+
// Plays synced during a long build do not stop it: the reading is saved as current up to the
|
|
79
|
+
// plays it read. A changed Like or Avoid does, since the reading could contradict it.
|
|
80
|
+
const assertCurrentInput = () => {
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
if (!sameListenerChoices(input.evidence, application.getProfileBuildContext().input.evidence)) {
|
|
83
|
+
throw new Error("Your choices changed during the build. Use /profile build to read the updated evidence.");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
// The checkpoint commits before memory changes, so a lost lease leaves both unchanged.
|
|
87
|
+
const save = (next, apply = () => {}) => {
|
|
88
|
+
store.checkpointFinalization(build, next);
|
|
89
|
+
apply();
|
|
90
|
+
finalization = next;
|
|
91
|
+
stageFinished = true;
|
|
92
|
+
onProgress(progress());
|
|
93
|
+
};
|
|
94
|
+
const session = {
|
|
95
|
+
phase: phase(), manifest: input.manifest, dossier: listenerProfileDossier(input), progress,
|
|
96
|
+
language: application.locale ?? "en",
|
|
97
|
+
get candidate() { return candidate; },
|
|
98
|
+
get check() { return latestCheck(); },
|
|
99
|
+
search: args => {
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
const result = searchListenerProfileEvidence(input, args);
|
|
102
|
+
for (const item of result.items) inspected.add(item.reference_id);
|
|
103
|
+
return result;
|
|
104
|
+
},
|
|
105
|
+
evidence: async (referenceId, offset = 0) => {
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError("Invalid evidence offset");
|
|
108
|
+
const entry = input.evidence.get(referenceId);
|
|
109
|
+
if (!entry) throw new TypeError("Unknown profile evidence reference");
|
|
110
|
+
const result = modelEvidence(entry, offset);
|
|
111
|
+
if (entry.data.evidence_id) {
|
|
112
|
+
try {
|
|
113
|
+
const detail = await application.explainProfileEvidence({ evidenceId: entry.data.evidence_id });
|
|
114
|
+
// The prose basis restates the row's measures, including any this input leaves out as unrecorded.
|
|
115
|
+
result.explanation = { ...(input.unrecorded?.has(referenceId) ? {} : { basis: detail.basis_summary }),
|
|
116
|
+
limitation: detail.interpretation_limit ?? detail.limitation };
|
|
117
|
+
} catch { /* The frozen row remains the source for aggregate-only evidence. */ }
|
|
118
|
+
}
|
|
119
|
+
inspected.add(referenceId);
|
|
120
|
+
return result;
|
|
121
|
+
},
|
|
122
|
+
modelRetry: retry => { if (!signal?.aborted) onProgress({ ...progress(), model_retry: retry }); },
|
|
123
|
+
submit: value => {
|
|
124
|
+
signal?.throwIfAborted();
|
|
125
|
+
if (!["synthesis", "revision"].includes(session.phase)) throw new Error("Submit a profile only while writing or revising it");
|
|
126
|
+
if (stageFinished) throw new Error("Submit one complete profile per session");
|
|
127
|
+
const next = compileListenerProfile(input, value);
|
|
128
|
+
const nextCandidate = listenerProfileCandidate(input, next);
|
|
129
|
+
save({ ...finalization, draft: { summary: nextCandidate.summary, insights: nextCandidate.insights },
|
|
130
|
+
revisions: finalization.revisions + Number(session.phase === "revision") }, () => {
|
|
131
|
+
compiled = next;
|
|
132
|
+
candidate = nextCandidate;
|
|
133
|
+
});
|
|
134
|
+
return { state: "draft_saved", next: "check" };
|
|
135
|
+
},
|
|
136
|
+
record: value => {
|
|
137
|
+
signal?.throwIfAborted();
|
|
138
|
+
if (session.phase !== "check" || stageFinished) throw new Error("Record one check in the separate check session");
|
|
139
|
+
const check = validateListenerProfileCheck(candidate, value);
|
|
140
|
+
save({ ...finalization, checks: [...finalization.checks, check] });
|
|
141
|
+
return { verdict: check.verdict, issues: check.issues.length };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const abort = () => runtime.abort?.();
|
|
145
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
146
|
+
try {
|
|
147
|
+
signal?.throwIfAborted();
|
|
148
|
+
onProgress(progress());
|
|
149
|
+
let sessions = 0;
|
|
150
|
+
while (phase() !== "complete") {
|
|
151
|
+
signal?.throwIfAborted();
|
|
152
|
+
session.phase = phase();
|
|
153
|
+
stageFinished = false;
|
|
154
|
+
if (++sessions > MAX_SESSIONS) {
|
|
155
|
+
throw new Error("The profile build reached its session limit. Your previous reading and the saved draft are kept. Use /profile build to continue.");
|
|
156
|
+
}
|
|
157
|
+
const inspectedBefore = inspected.size;
|
|
158
|
+
await runtime.investigate(session);
|
|
159
|
+
signal?.throwIfAborted();
|
|
160
|
+
assertCurrentInput();
|
|
161
|
+
if (!stageFinished && inspected.size === inspectedBefore) {
|
|
162
|
+
throw new Error("The profile model stopped making progress. Your previous reading and any saved draft are kept. Use /profile build to retry.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const check = latestCheck();
|
|
166
|
+
store.complete(build, { ...compiled, verification: {
|
|
167
|
+
version: LISTENER_PROFILE_CHECK_VERSION,
|
|
168
|
+
state: check.verdict === "pass" ? (finalization.revisions ? "revised" : "passed") : "flagged",
|
|
169
|
+
revisions: finalization.revisions, checks: finalization.checks,
|
|
170
|
+
} }, assertCurrentInput);
|
|
171
|
+
return { state: "ready", revision: await application.getListenerProfile(), ...progress() };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
store.interrupt(build);
|
|
174
|
+
throw error;
|
|
175
|
+
} finally {
|
|
176
|
+
signal?.removeEventListener("abort", abort);
|
|
177
|
+
runtime.abort?.();
|
|
178
|
+
}
|
|
179
|
+
}
|