@inerate/acri-core 0.1.0

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 ADDED
@@ -0,0 +1,48 @@
1
+ # @inerate/acri-core (TypeScript)
2
+
3
+ A minimal TypeScript port of acri's core resolver: `Tool`/`Corpus`/`index()` and
4
+ BM25 `resolve()` — the v0.1 scope `docs/decisions.md` (the Python repo's own
5
+ design doc) calls "a complete product on its own." Nothing else is ported: no
6
+ port/daemon/server/cli/config/gate/press/sandbox/ledger/router/adapters/
7
+ escape-hatch.
8
+
9
+ Install: `npm install @inerate/acri-core` —
10
+ [npmjs.com/package/@inerate/acri-core](https://www.npmjs.com/package/@inerate/acri-core).
11
+ Versions independently at `0.1.x`, not tracking the Python package.
12
+
13
+ Mirrors the Python implementation's behavior directly, not just its shape:
14
+ same BM25 constants (`K1=1.5`, `B=0.75`), same tokenizer (lowercase, strip
15
+ `'`/`'`, `[a-z0-9]+`, the same stopword list), same query-only synonym
16
+ expansion (never applied to indexed tool text — see `src/synonyms.ts`'s
17
+ comment for why that matters), same `resolve()` semantics: zero-score tools
18
+ are dropped rather than padded in, the top score normalizes to 1.0, `k` is
19
+ respected. `test/compass.test.ts` mirrors `tests/test_compass.py`'s five
20
+ cases exactly, same fixture and queries, and passes.
21
+
22
+ ## Shipped ahead of its own gate
23
+
24
+ `docs/decisions.md`:
25
+
26
+ > **TypeScript** | A real gap, and the JavaScript agent ecosystem is large.
27
+ > Port after the Python package has users, never in parallel with it.
28
+
29
+ `pyacri` is live on PyPI (has been since v0.4.0) with real download activity
30
+ (547 in the last 30 days — [pypistats.org/packages/pyacri](https://pypistats.org/packages/pyacri),
31
+ checked live, not download count alone confused for "real users"). Shipping
32
+ this in parallel with it, rather than after evidence someone specifically
33
+ wants the TypeScript side, is exactly the scenario that line warns against:
34
+ it doubles the maintenance surface for every future acri feature on a guess,
35
+ not a measured need.
36
+ Built anyway at the maintainer's explicit request — the same override pattern
37
+ used elsewhere in this project (`gate`, `press`, `sandbox`, and the `daemon`
38
+ all shipped ahead of their own gates too; see the root
39
+ [`README.md`](../README.md)'s Roadmap table). Stated here plainly, not
40
+ softened, so this isn't mistaken for a maintained, gate-cleared parallel
41
+ implementation — it's a v0.1-equivalent skeleton, nothing more.
42
+
43
+ ## Run
44
+
45
+ ```
46
+ npm install
47
+ npm test
48
+ ```
@@ -0,0 +1,7 @@
1
+ import type { Corpus, Tool } from "./corpus.js";
2
+ export interface Resolved {
3
+ tool: Tool;
4
+ score: number;
5
+ }
6
+ export declare function bm25(queryTokens: string[], corpus: Corpus, docIdx: number): number;
7
+ export declare function resolve(query: string, corpus: Corpus, k?: number): Resolved[];
@@ -0,0 +1,43 @@
1
+ // compass — the resolver. Given a query, returns the k tools that matter.
2
+ // Ported from acri/compass.py. No language understanding: BM25 scores query
3
+ // terms against tool text, weighting terms that are rare in the corpus.
4
+ import { tokenize } from "./text.js";
5
+ import { expand } from "./synonyms.js";
6
+ const K1 = 1.5;
7
+ const B = 0.75;
8
+ function idf(df, nDocs) {
9
+ return Math.log(1 + (nDocs - df + 0.5) / (df + 0.5));
10
+ }
11
+ export function bm25(queryTokens, corpus, docIdx) {
12
+ const docLen = corpus.docTokens[docIdx].length;
13
+ const freqs = corpus.docFreqs[docIdx];
14
+ const nDocs = corpus.tools.length;
15
+ let score = 0;
16
+ for (const tok of queryTokens) {
17
+ const f = freqs.get(tok);
18
+ if (!f)
19
+ continue;
20
+ const termIdf = idf(corpus.df.get(tok) ?? 0, nDocs);
21
+ const denom = f + K1 * (1 - B + (B * docLen) / corpus.avgdl);
22
+ score += (termIdf * (f * (K1 + 1))) / denom;
23
+ }
24
+ return score;
25
+ }
26
+ // Tools that score zero (no shared term with the query) are dropped rather
27
+ // than padded in -- an empty result means "nothing in this corpus matches".
28
+ export function resolve(query, corpus, k = 5) {
29
+ if (corpus.tools.length === 0)
30
+ return [];
31
+ const queryTokens = expand(tokenize(query));
32
+ const raw = corpus.tools.map((_, i) => bm25(queryTokens, corpus, i));
33
+ const top = Math.max(...raw);
34
+ if (top <= 0)
35
+ return [];
36
+ const scored = [];
37
+ for (let i = 0; i < corpus.tools.length; i++) {
38
+ if (raw[i] > 0)
39
+ scored.push({ tool: corpus.tools[i], score: raw[i] / top });
40
+ }
41
+ scored.sort((a, b) => b.score - a.score);
42
+ return scored.slice(0, k);
43
+ }
@@ -0,0 +1,13 @@
1
+ export interface Tool {
2
+ name: string;
3
+ description: string;
4
+ parameters?: Record<string, unknown>;
5
+ }
6
+ export interface Corpus {
7
+ tools: Tool[];
8
+ docTokens: string[][];
9
+ docFreqs: Map<string, number>[];
10
+ df: Map<string, number>;
11
+ avgdl: number;
12
+ }
13
+ export declare function index(tools: Tool[]): Corpus;
@@ -0,0 +1,23 @@
1
+ // corpus — the capability index. Ingests tools into one searchable body, once.
2
+ // Ported from acri/corpus.py. `handler` (Python's optional dispatcher field)
3
+ // is not ported -- nothing in this resolver-only port calls it, same as the
4
+ // Python side ("resolution never calls it").
5
+ import { tokenize } from "./text.js";
6
+ export function index(tools) {
7
+ if (tools.length === 0) {
8
+ throw new Error("index() needs at least one tool");
9
+ }
10
+ const docTokens = tools.map((t) => tokenize(`${t.name} ${t.description}`));
11
+ const docFreqs = [];
12
+ const df = new Map();
13
+ for (const tokens of docTokens) {
14
+ const freqs = new Map();
15
+ for (const tok of tokens)
16
+ freqs.set(tok, (freqs.get(tok) ?? 0) + 1);
17
+ docFreqs.push(freqs);
18
+ for (const tok of freqs.keys())
19
+ df.set(tok, (df.get(tok) ?? 0) + 1);
20
+ }
21
+ const avgdl = docTokens.reduce((sum, t) => sum + t.length, 0) / docTokens.length;
22
+ return { tools: [...tools], docTokens, docFreqs, df, avgdl };
23
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./corpus.js";
2
+ export * from "./compass.js";
3
+ export * from "./text.js";
4
+ export * from "./synonyms.js";
@@ -0,0 +1,4 @@
1
+ export * from "./corpus.js";
2
+ export * from "./compass.js";
3
+ export * from "./text.js";
4
+ export * from "./synonyms.js";
@@ -0,0 +1,2 @@
1
+ export declare const ALIASES: Readonly<Record<string, readonly string[]>>;
2
+ export declare function expand(tokens: string[]): string[];
@@ -0,0 +1,25 @@
1
+ // synonyms — query-side alias expansion for compass. Not corpus-facing.
2
+ // Ported from acri/_synonyms.py. Applied to the query only, never the corpus:
3
+ // expanding doc text here would let a tool's description quietly start
4
+ // matching queries for a synonym it never claimed, and would shift df/idf
5
+ // for every other tool too. Every entry traces to a real recall@5 miss in
6
+ // the Python repo's assay/diagnose.py, not a guess.
7
+ export const ALIASES = {
8
+ pr: ["pull", "request"],
9
+ meeting: ["event"],
10
+ rain: ["weather"],
11
+ raining: ["weather"],
12
+ storm: ["weather"],
13
+ warning: ["alerts"],
14
+ warnings: ["alerts"],
15
+ text: ["sms", "message"],
16
+ sharpen: ["upscale", "resolution"],
17
+ money: ["refund", "charge"],
18
+ };
19
+ export function expand(tokens) {
20
+ const expanded = [...tokens];
21
+ for (const tok of tokens) {
22
+ expanded.push(...(ALIASES[tok] ?? []));
23
+ }
24
+ return expanded;
25
+ }
@@ -0,0 +1 @@
1
+ export declare function tokenize(text: string): string[];
@@ -0,0 +1,25 @@
1
+ // text — tokenization shared by corpus (indexing) and compass (scoring).
2
+ // Ported from acri/_text.py -- both sides must tokenize identically or BM25
3
+ // scores a query against tokens the corpus was never split the same way.
4
+ const TOKEN_RE = /[a-z0-9]+/g;
5
+ // Same list as acri/_text.py's _STOPWORDS (the NLTK stoplist's
6
+ // non-contraction core) -- ported verbatim, not re-derived.
7
+ const STOPWORDS = new Set([
8
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
9
+ "am", "of", "in", "on", "at", "to", "for", "with", "and", "or",
10
+ "but", "this", "that", "these", "those", "what", "which", "who", "whom", "me",
11
+ "my", "you", "your", "it", "its", "we", "they", "do", "does", "did",
12
+ "into", "from", "by", "about", "up", "down", "out", "off", "over", "under",
13
+ "again", "further", "then", "once", "here", "there", "when", "where", "why", "how",
14
+ "all", "any", "both", "each", "few", "more", "most", "other", "some", "such",
15
+ "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
16
+ "s", "t", "can", "will", "just", "now", "if", "because", "as", "until",
17
+ "while", "against", "between", "during", "before", "after", "above", "below",
18
+ ]);
19
+ export function tokenize(text) {
20
+ // Strip apostrophes before splitting: "user's" -> "users" as one token,
21
+ // not "user" + a stray "s" that then matches every other possessive.
22
+ const normalized = text.toLowerCase().replace(/'/g, "").replace(/’/g, "");
23
+ const matches = normalized.match(TOKEN_RE) ?? [];
24
+ return matches.filter((t) => !STOPWORDS.has(t));
25
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@inerate/acri-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Minimal TypeScript port of acri capability resolver (corpus + compass)",
6
+ "main": "dist/src/index.js",
7
+ "types": "dist/src/index.d.ts",
8
+ "files": [
9
+ "dist/src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "tsc && node --test dist/test/compass.test.js"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.6.0",
17
+ "@types/node": "^22.0.0"
18
+ },
19
+ "license": "MIT",
20
+ "homepage": "https://forge.inerate.com/acri",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/INERATE/acri.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/INERATE/acri/issues"
27
+ }
28
+ }