@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 +48 -0
- package/dist/src/compass.d.ts +7 -0
- package/dist/src/compass.js +43 -0
- package/dist/src/corpus.d.ts +13 -0
- package/dist/src/corpus.js +23 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/synonyms.d.ts +2 -0
- package/dist/src/synonyms.js +25 -0
- package/dist/src/text.d.ts +1 -0
- package/dist/src/text.js +25 -0
- package/package.json +28 -0
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,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[];
|
package/dist/src/text.js
ADDED
|
@@ -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
|
+
}
|