@fangorn-network/westmarch 0.0.1-dev
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.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/consume/steam.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// Your Steam library as a taste, without asking Steam.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE IS NOT AN API CLIENT
|
|
4
|
+
// ----------------------------------
|
|
5
|
+
// The obvious way to seed a game recommender is Steam's Web API: issue a key,
|
|
6
|
+
// call GetOwnedGames, receive the library. It works, and it means the first
|
|
7
|
+
// thing this product does — before it has recommended anything — is tell Valve
|
|
8
|
+
// that you asked. For a recommender whose entire argument is that your taste
|
|
9
|
+
// stays yours, that is the wrong first move.
|
|
10
|
+
//
|
|
11
|
+
// Steam already wrote your playtime to your own disk. `localconfig.vdf`, in the
|
|
12
|
+
// Steam install under `userdata/<accountid>/config/`, holds one entry per app
|
|
13
|
+
// you have launched with `Playtime` in minutes and `LastPlayed` as a unix
|
|
14
|
+
// timestamp. It is on the machine of everyone who has Steam installed, it needs
|
|
15
|
+
// no key, no login and no network, and reading it is not a request anybody can
|
|
16
|
+
// log.
|
|
17
|
+
//
|
|
18
|
+
// So the consumer path is a file the reader hands over, parsed where it is
|
|
19
|
+
// handed over — in the tab, in this module, going nowhere. In a browser that is
|
|
20
|
+
// a drop target; in Node it is a path. Same parser, same mapping, and the
|
|
21
|
+
// deployment story is "you already have this file" rather than "create an API
|
|
22
|
+
// key".
|
|
23
|
+
//
|
|
24
|
+
// WHAT PLAYTIME IS ALLOWED TO MEAN
|
|
25
|
+
// --------------------------------
|
|
26
|
+
// `taste.js` takes likes and dislikes, weights them by RECENCY on a half-life,
|
|
27
|
+
// and infers a heading from the order. It does not take weights, and this file
|
|
28
|
+
// does not add any: playtime decides MEMBERSHIP, `LastPlayed` decides order.
|
|
29
|
+
// Sorting by hours instead would tell the kernel that a game you adored in 2021
|
|
30
|
+
// is where you are now, which is the one thing the half-life exists to deny.
|
|
31
|
+
//
|
|
32
|
+
// The three-way split is the part that has to be defensible, because a wrong
|
|
33
|
+
// dislike actively steers recommendations away from things you would like —
|
|
34
|
+
// `taste.js` applies GAMMA 0.6 to rejections, which is deliberately strong:
|
|
35
|
+
//
|
|
36
|
+
// played a lot → a like. You kept coming back.
|
|
37
|
+
// launched, bounced → a dislike. You tried it and stopped, which is the
|
|
38
|
+
// only negative signal a library actually contains.
|
|
39
|
+
// never launched → NEITHER. An unplayed game in a library is a sale, a
|
|
40
|
+
// bundle or a backlog, and reading it as a rejection
|
|
41
|
+
// would punish you for things you have not got to yet.
|
|
42
|
+
//
|
|
43
|
+
// That last distinction is why `lastPlayed` is checked separately from
|
|
44
|
+
// `playtime` rather than folded into one threshold.
|
|
45
|
+
|
|
46
|
+
/** Minutes of play at or above which a game counts as a like. Two hours is the
|
|
47
|
+
* refund window: past it you made a decision to keep playing. */
|
|
48
|
+
export const LIKE_MINUTES = 120;
|
|
49
|
+
|
|
50
|
+
/** Minutes below which a LAUNCHED game counts as a bounce. Twenty minutes is
|
|
51
|
+
* about a tutorial — long enough that "I never actually opened it" is not the
|
|
52
|
+
* explanation, short enough that "I kept playing" is not either. */
|
|
53
|
+
export const BOUNCE_MINUTES = 20;
|
|
54
|
+
|
|
55
|
+
// ── VDF ─────────────────────────────────────────────────────────────────────
|
|
56
|
+
// Valve's KeyValues: `"key" "value"` pairs and `"key" { … }` blocks, tab
|
|
57
|
+
// indented, with `//` line comments. A few hundred bytes of parser, and no
|
|
58
|
+
// dependency — the alternative is a package that reads a file format that has
|
|
59
|
+
// not changed since 2003.
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* VDF text → a nested plain object. Duplicate keys: last wins.
|
|
63
|
+
*
|
|
64
|
+
* Values keep their string form. Everything in this file that wants a number
|
|
65
|
+
* coerces at the point of use, because a VDF holds `"0"` and `""` for the same
|
|
66
|
+
* idea and a parser that guessed would hide that from the caller.
|
|
67
|
+
*/
|
|
68
|
+
export function parseVdf(text) {
|
|
69
|
+
const s = String(text ?? "");
|
|
70
|
+
let i = 0;
|
|
71
|
+
|
|
72
|
+
const skip = () => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
75
|
+
if (s[i] === "/" && s[i + 1] === "/") {
|
|
76
|
+
while (i < s.length && s[i] !== "\n") i++;
|
|
77
|
+
} else return;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// A token is either a quoted string (with backslash escapes) or a bare run.
|
|
82
|
+
const token = () => {
|
|
83
|
+
skip();
|
|
84
|
+
if (i >= s.length) return null;
|
|
85
|
+
const c = s[i];
|
|
86
|
+
if (c === "{" || c === "}") { i++; return c; }
|
|
87
|
+
if (c === '"') {
|
|
88
|
+
i++;
|
|
89
|
+
let out = "";
|
|
90
|
+
while (i < s.length && s[i] !== '"') {
|
|
91
|
+
if (s[i] === "\\" && i + 1 < s.length) { out += s[++i]; i++; }
|
|
92
|
+
else out += s[i++];
|
|
93
|
+
}
|
|
94
|
+
i++; // closing quote
|
|
95
|
+
return { str: out };
|
|
96
|
+
}
|
|
97
|
+
let out = "";
|
|
98
|
+
while (i < s.length && !/[\s{}"]/.test(s[i])) out += s[i++];
|
|
99
|
+
return out ? { str: out } : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const block = () => {
|
|
103
|
+
const obj = {};
|
|
104
|
+
for (;;) {
|
|
105
|
+
const k = token();
|
|
106
|
+
if (k === null || k === "}") return obj;
|
|
107
|
+
if (k === "{") continue; // stray brace — tolerate
|
|
108
|
+
const v = token();
|
|
109
|
+
if (v === null) return obj;
|
|
110
|
+
obj[k.str] = v === "{" ? block() : (typeof v === "object" ? v.str : "");
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const root = {};
|
|
115
|
+
for (;;) {
|
|
116
|
+
const k = token();
|
|
117
|
+
if (k === null) break;
|
|
118
|
+
if (k === "}" || k === "{") continue;
|
|
119
|
+
const v = token();
|
|
120
|
+
if (v === null) break;
|
|
121
|
+
root[k.str] = v === "{" ? block() : (typeof v === "object" ? v.str : "");
|
|
122
|
+
}
|
|
123
|
+
return root;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Find the `apps` map anywhere in a parsed config, by SHAPE rather than by path.
|
|
130
|
+
*
|
|
131
|
+
* The documented location is UserLocalConfigStore ▸ Software ▸ Valve ▸ Steam ▸
|
|
132
|
+
* apps, but the casing of those segments is not stable across Steam versions
|
|
133
|
+
* (`valve` and `Valve` both occur in the wild, in the same install), and a
|
|
134
|
+
* hardcoded path that silently misses returns an empty library rather than an
|
|
135
|
+
* error. So: walk the tree, and take any object whose children are numeric keys
|
|
136
|
+
* carrying a Playtime or LastPlayed. Nothing else in the file looks like that.
|
|
137
|
+
*/
|
|
138
|
+
function findApps(node, depth = 0) {
|
|
139
|
+
if (!node || typeof node !== "object" || depth > 8) return null;
|
|
140
|
+
let best = null;
|
|
141
|
+
for (const [key, val] of Object.entries(node)) {
|
|
142
|
+
if (!val || typeof val !== "object") continue;
|
|
143
|
+
if (key.toLowerCase() === "apps") {
|
|
144
|
+
const hit = Object.entries(val).filter(
|
|
145
|
+
([k, v]) => /^\d+$/.test(k) && v && typeof v === "object" &&
|
|
146
|
+
("Playtime" in v || "LastPlayed" in v));
|
|
147
|
+
// Prefer the richest such block — `sharedconfig.vdf` has an `apps`
|
|
148
|
+
// holding only tags, and an install with two of them should not be
|
|
149
|
+
// decided by key order.
|
|
150
|
+
if (hit.length && (!best || hit.length > best.length)) best = hit;
|
|
151
|
+
}
|
|
152
|
+
const deeper = findApps(val, depth + 1);
|
|
153
|
+
if (deeper && (!best || deeper.length > best.length)) best = deeper;
|
|
154
|
+
}
|
|
155
|
+
return best;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `localconfig.vdf` text → the library, oldest-played first.
|
|
160
|
+
*
|
|
161
|
+
* Oldest first because that is the order `taste.js` wants: it weights by
|
|
162
|
+
* position with the newest LAST, so handing it this array directly makes the
|
|
163
|
+
* half-life mean what it says. Entries Steam has recorded but never timestamped
|
|
164
|
+
* sort to the front, where they carry the least weight — which is also where
|
|
165
|
+
* something you have never launched belongs.
|
|
166
|
+
*/
|
|
167
|
+
export function steamLibrary(vdfText) {
|
|
168
|
+
const apps = findApps(parseVdf(vdfText)) ?? [];
|
|
169
|
+
return apps
|
|
170
|
+
.map(([appid, v]) => ({
|
|
171
|
+
appid: Number(appid),
|
|
172
|
+
// Steam splits time across `Playtime` and `PlaytimeDisconnected`
|
|
173
|
+
// (offline sessions). Only counting the first understates anyone
|
|
174
|
+
// who plays on a laptop.
|
|
175
|
+
playtime: num(v.Playtime) + num(v.PlaytimeDisconnected),
|
|
176
|
+
lastPlayed: num(v.LastPlayed),
|
|
177
|
+
}))
|
|
178
|
+
.filter((g) => g.appid > 0)
|
|
179
|
+
.sort((a, b) => a.lastPlayed - b.lastPlayed);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** How a single library entry is read. Exported so a caller can show the
|
|
183
|
+
* reader why a game landed where it did, and so the thresholds are testable
|
|
184
|
+
* without building a whole taste. */
|
|
185
|
+
export function verdictFor({ playtime = 0, lastPlayed = 0 } = {}) {
|
|
186
|
+
if (playtime >= LIKE_MINUTES) return "like";
|
|
187
|
+
if (lastPlayed > 0 && playtime < BOUNCE_MINUTES) return "bounce";
|
|
188
|
+
return "neither";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const defaultTitle = (r) => r?.name ?? r?.title ?? r?.id ?? "";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A library plus a corpus → the `{likes, dislikes}` `taste()` takes.
|
|
195
|
+
*
|
|
196
|
+
* Rows are matched on `appid`, which is the games corpus's declared identity
|
|
197
|
+
* field, coerced because a shard may carry it as a number or a string. A row's
|
|
198
|
+
* vector is copied straight off the row — nothing is embedded, so this is
|
|
199
|
+
* instant and works with no model loaded.
|
|
200
|
+
*
|
|
201
|
+
* Returns the unmatched appids too. A library is always bigger than any corpus
|
|
202
|
+
* (DLC, delisted games, tools, and whatever the crawl has not reached yet), and
|
|
203
|
+
* a seed that silently dropped half of someone's hours would be indistinguishable
|
|
204
|
+
* from one that worked.
|
|
205
|
+
*/
|
|
206
|
+
export function seedTaste(library, rows, { title = defaultTitle } = {}) {
|
|
207
|
+
const byId = new Map();
|
|
208
|
+
for (const r of rows ?? []) {
|
|
209
|
+
const a = Number(r?.appid);
|
|
210
|
+
if (Number.isFinite(a) && a > 0 && !byId.has(a)) byId.set(a, r);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const likes = [], dislikes = [], unmatched = [];
|
|
214
|
+
let played = 0;
|
|
215
|
+
for (const g of library ?? []) {
|
|
216
|
+
const row = byId.get(g.appid);
|
|
217
|
+
if (!row) { unmatched.push(g); continue; }
|
|
218
|
+
if (g.playtime > 0) played += g.playtime;
|
|
219
|
+
const entry = { id: row.id ?? `steam:app:${g.appid}`, title: title(row),
|
|
220
|
+
vector: row.vector, appid: g.appid, playtime: g.playtime,
|
|
221
|
+
lastPlayed: g.lastPlayed };
|
|
222
|
+
const v = verdictFor(g);
|
|
223
|
+
if (v === "like" && row.vector) likes.push(entry);
|
|
224
|
+
else if (v === "bounce" && row.vector) dislikes.push(entry);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
likes, dislikes, unmatched,
|
|
228
|
+
matched: library ? library.length - unmatched.length : 0,
|
|
229
|
+
hours: Math.round(played / 60),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A one-line account of what a seed actually used, for the reader who is
|
|
234
|
+
* about to be recommended things because of it. */
|
|
235
|
+
export const summarize = (seed) =>
|
|
236
|
+
`${seed.matched} of ${seed.matched + seed.unmatched.length} games matched the corpus · ` +
|
|
237
|
+
`${seed.hours} hours · ${seed.likes.length} liked, ${seed.dislikes.length} bounced off`;
|
|
238
|
+
|
|
239
|
+
// ── self-check: `node consume/steam.js` ─────────────────────────────────────
|
|
240
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
241
|
+
// A localconfig.vdf, cut down but structurally exact — including the mixed
|
|
242
|
+
// casing of the Valve/valve segment that defeats a hardcoded path.
|
|
243
|
+
const VDF = `
|
|
244
|
+
"UserLocalConfigStore"
|
|
245
|
+
{
|
|
246
|
+
"Software"
|
|
247
|
+
{
|
|
248
|
+
"valve"
|
|
249
|
+
{
|
|
250
|
+
"Steam"
|
|
251
|
+
{
|
|
252
|
+
"apps"
|
|
253
|
+
{
|
|
254
|
+
"730"
|
|
255
|
+
{
|
|
256
|
+
"LastPlayed" "1788064199"
|
|
257
|
+
"Playtime" "4240"
|
|
258
|
+
}
|
|
259
|
+
"381210"
|
|
260
|
+
{
|
|
261
|
+
"LastPlayed" "1787000000"
|
|
262
|
+
"Playtime" "47500"
|
|
263
|
+
"PlaytimeDisconnected" "280"
|
|
264
|
+
}
|
|
265
|
+
"570"
|
|
266
|
+
{
|
|
267
|
+
"LastPlayed" "1525632471"
|
|
268
|
+
"Playtime" "6"
|
|
269
|
+
}
|
|
270
|
+
"12345"
|
|
271
|
+
{
|
|
272
|
+
"Playtime" "0"
|
|
273
|
+
}
|
|
274
|
+
"999999"
|
|
275
|
+
{
|
|
276
|
+
"LastPlayed" "1600000000"
|
|
277
|
+
"Playtime" "3000"
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}`;
|
|
284
|
+
|
|
285
|
+
const lib = steamLibrary(VDF);
|
|
286
|
+
if (lib.length !== 5) throw new Error(`five apps, got ${lib.length}`);
|
|
287
|
+
// Oldest first — the order taste.js weights by position expects.
|
|
288
|
+
if (lib[lib.length - 1].appid !== 730) throw new Error("newest-played must sort LAST");
|
|
289
|
+
if (lib[0].appid !== 12345) throw new Error("an entry with no LastPlayed sorts first, where it counts least");
|
|
290
|
+
const dbd = lib.find((g) => g.appid === 381210);
|
|
291
|
+
if (dbd.playtime !== 47500 + 280) throw new Error("offline playtime must count — it is the same hours");
|
|
292
|
+
|
|
293
|
+
// The three-way split, which is the only judgement this file makes.
|
|
294
|
+
if (verdictFor({ playtime: 4240, lastPlayed: 1 }) !== "like") throw new Error("70 hours is a like");
|
|
295
|
+
if (verdictFor({ playtime: 6, lastPlayed: 1 }) !== "bounce") throw new Error("launched and quit at 6 minutes is a bounce");
|
|
296
|
+
if (verdictFor({ playtime: 0, lastPlayed: 0 }) !== "neither") throw new Error("never launched is NOT a rejection");
|
|
297
|
+
if (verdictFor({ playtime: 60, lastPlayed: 1 }) !== "neither") throw new Error("an hour is neither — it is ambiguous and must stay so");
|
|
298
|
+
|
|
299
|
+
// Mapping onto a corpus.
|
|
300
|
+
const row = (appid, name, vector) => ({ id: `steam:app:${appid}`, appid, name, vector: Float32Array.from(vector) });
|
|
301
|
+
const rows = [row(730, "Counter-Strike 2", [1, 0]), row(381210, "Dead by Daylight", [0.9, 0.1]),
|
|
302
|
+
row(570, "Dota 2", [0, 1]), row(12345, "Unplayed Thing", [0, 1])];
|
|
303
|
+
|
|
304
|
+
const seed = seedTaste(lib, rows);
|
|
305
|
+
if (seed.likes.map((l) => l.appid).join() !== "381210,730") throw new Error(`likes wrong / mis-ordered: ${seed.likes.map((l) => l.appid)}`);
|
|
306
|
+
if (seed.dislikes.length !== 1 || seed.dislikes[0].appid !== 570) throw new Error("the bounce must be the only dislike");
|
|
307
|
+
if (seed.likes.some((l) => l.appid === 12345) || seed.dislikes.some((d) => d.appid === 12345))
|
|
308
|
+
throw new Error("an unplayed game must not enter the taste at all");
|
|
309
|
+
if (seed.matched !== 4 || seed.unmatched[0].appid !== 999999)
|
|
310
|
+
throw new Error("a library game absent from the corpus must be reported, not silently dropped");
|
|
311
|
+
if (seed.hours !== Math.round((4240 + 47780 + 6) / 60)) throw new Error(`hours wrong: ${seed.hours}`);
|
|
312
|
+
|
|
313
|
+
// A shard may carry appid as a string; a seed that missed on that would
|
|
314
|
+
// match nothing and look exactly like a library with no games in the corpus.
|
|
315
|
+
const strRows = rows.map((r) => ({ ...r, appid: String(r.appid) }));
|
|
316
|
+
if (seedTaste(lib, strRows).likes.length !== 2) throw new Error("appid must match across string/number");
|
|
317
|
+
|
|
318
|
+
// A row with no vector cannot enter a taste — it would be a like the kernel
|
|
319
|
+
// cannot average.
|
|
320
|
+
if (seedTaste(lib, [{ id: "x", appid: 730, name: "no vector" }]).likes.length)
|
|
321
|
+
throw new Error("a vectorless row must not become a like");
|
|
322
|
+
|
|
323
|
+
// …and the whole point: this seed drives taste.js unchanged.
|
|
324
|
+
const { taste, recommend } = await import("./taste.js");
|
|
325
|
+
const t = taste(seed.likes, seed.dislikes);
|
|
326
|
+
if (!t) throw new Error("a real library must produce a taste");
|
|
327
|
+
const pool = [row(1, "another shooter", [0.95, 0.05]), row(2, "another moba", [0.05, 0.95])];
|
|
328
|
+
if (recommend(pool, t)[0].row.name !== "another shooter")
|
|
329
|
+
throw new Error("a taste seeded from Steam must rank an unseen pool by it");
|
|
330
|
+
|
|
331
|
+
// Degenerate inputs return nothing rather than throwing — this parser is
|
|
332
|
+
// pointed at a file a stranger dropped on a page.
|
|
333
|
+
if (steamLibrary("").length || steamLibrary("garbage {{{").length) throw new Error("junk must parse to an empty library, not throw");
|
|
334
|
+
if (seedTaste([], rows).likes.length || seedTaste(null, null).matched !== 0) throw new Error("an empty library is an empty seed");
|
|
335
|
+
if (parseVdf('"a" "b" // trailing comment\n').a !== "b") throw new Error("comments must be skipped");
|
|
336
|
+
if (parseVdf('"a" "say \\"hi\\""').a !== 'say "hi"') throw new Error("escaped quotes must survive");
|
|
337
|
+
|
|
338
|
+
console.log(`steam.js self-check ok — VDF parsed by shape not path, offline hours counted, ` +
|
|
339
|
+
`newest-played sorts last for the half-life, unplayed is not a rejection, ` +
|
|
340
|
+
`${summarize(seed)}`);
|
|
341
|
+
}
|