terminal-ai-helper 2.3.1 → 2.3.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/VERSION +1 -1
- package/package.json +1 -1
- package/plugins/bash/keys.bash +22 -3
- package/plugins/zsh/menu.zsh +18 -0
- package/plugins/zsh/widgets.zsh +6 -0
- package/readme.md +1 -1
- package/tai/cli.py +12 -6
- package/tai/engcache.py +35 -10
- package/tai/engine.py +22 -6
- package/tai/fresh.py +7 -2
- package/tai/index.py +7 -1
- package/tai/maintenance.py +9 -1
- package/tai/paths.py +91 -5
- package/tai/predictor.py +116 -12
- package/tai/spool.py +6 -3
- package/tai/store.py +26 -3
- package/tai/typo.py +41 -15
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.3.
|
|
1
|
+
2.3.3
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-ai-helper",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.3",
|
|
4
4
|
"description": "The terminal that knows your next command Bash/Zsh suggestions that learn from your own history Offline and private with no account required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/plugins/bash/keys.bash
CHANGED
|
@@ -15,6 +15,12 @@ _tai_prompt() {
|
|
|
15
15
|
raw="${raw#"${raw%%[![:space:]]*}"}" # drop the leading indent
|
|
16
16
|
while [[ "$raw" == [0-9]* ]]; do raw="${raw#?}"; done # drop the history number
|
|
17
17
|
last_cmd="${raw#"${raw%%[![:space:]]*}"}" # drop the space after it
|
|
18
|
+
# A completion's own space rides the line into the history (`nano ` after a
|
|
19
|
+
# menu choice), and the store strips it — but this shell's next prediction
|
|
20
|
+
# looks the last command up in the sequence table by its exact text, and the
|
|
21
|
+
# table's keys are the stored, stripped rows. Trim here, once per command,
|
|
22
|
+
# so `nano ` predicts what followed `nano` and records the same row either way.
|
|
23
|
+
while [[ "$last_cmd" == *[[:space:]] ]]; do last_cmd="${last_cmd%?}"; done
|
|
18
24
|
if [[ -n "$last_cmd" && "$last_cmd" != "$_TAI_LAST" ]]; then
|
|
19
25
|
_TAI_LAST="$last_cmd"
|
|
20
26
|
# The unit cache pre-loads itself in the background after a command that
|
|
@@ -247,12 +253,25 @@ _tai_complete() {
|
|
|
247
253
|
# by both loops above — once as the file that is here, once as the line the
|
|
248
254
|
# history holds — and COMPREPLY holding it twice makes readline print it twice.
|
|
249
255
|
_tai_reply() {
|
|
250
|
-
local c
|
|
256
|
+
local c x
|
|
251
257
|
# A learned line carrying raw control bytes is not a completion it is this
|
|
252
258
|
# plugin's promise to never paint: what it would write is escape bytes.
|
|
253
259
|
[[ "$1" =~ [[:cntrl:]] ]] && return 0
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
c="$1"
|
|
261
|
+
# A completion that leaves the line one bare word is a command waiting for
|
|
262
|
+
# its arguments, and readline's own completion would have written the space
|
|
263
|
+
# between them (`cat tzz_solo `). `-o nospace` took that append away from
|
|
264
|
+
# every entry so a directory's slash could stand alone, so the entries that
|
|
265
|
+
# want the space carry it — measured on a real shell, a match holding a
|
|
266
|
+
# trailing space is inserted verbatim, and the common prefix of matches that
|
|
267
|
+
# disagree past the space is the prefix alone. Three shapes carry none: a
|
|
268
|
+
# directory's slash, which is the path still being typed; a whole learned
|
|
269
|
+
# line, which is the line the user ran, and whose space would ride the next
|
|
270
|
+
# Enter into the history as a row of its own; and a name that already holds
|
|
271
|
+
# a space, which is more than one shell word already.
|
|
272
|
+
if [[ -n "$c" && "$c" != */ && "$c" != *[[:space:]]* ]]; then c="$c "; fi
|
|
273
|
+
for x in "${COMPREPLY[@]}"; do [[ "$x" == "$c" ]] && return 0; done
|
|
274
|
+
COMPREPLY+=("$c")
|
|
256
275
|
}
|
|
257
276
|
complete -o nospace -F _tai_complete tai 2>/dev/null
|
|
258
277
|
# -o default is what makes Tab fall back to readline's own filename completion
|
package/plugins/zsh/menu.zsh
CHANGED
|
@@ -769,6 +769,24 @@ _tai_menu_commit() {
|
|
|
769
769
|
# slash from truncating what gets written.
|
|
770
770
|
(( ${_TAI_MENU_Q[_TAI_MENU_IDX]} )) && { _tai_quote "$entry"; entry="$_TAI_QUOTED" }
|
|
771
771
|
BUFFER="${BUFFER[1,$(( _TAI_MENU_FROM - 1 ))]}$entry${BUFFER[_TAI_MENU_TO + 1,-1]}"
|
|
772
|
+
# A completion that leaves the line one bare word is a command waiting for its
|
|
773
|
+
# arguments, and the space between them is written for the user — every
|
|
774
|
+
# shell's own completion does this, and readline's half of this plugin always
|
|
775
|
+
# did (`cat tzz_solo `). The report: choosing `nano` out of the menu put
|
|
776
|
+
# `nano` in the line, and the user typed the space by hand, once per line,
|
|
777
|
+
# every time. Three shapes write nothing after them: a directory's slash,
|
|
778
|
+
# which is the path still being typed; a whole learned line, which is the
|
|
779
|
+
# line the user ran, and whose space would ride the next Enter into the
|
|
780
|
+
# history as a row of its own; and a name that already holds a space, whose
|
|
781
|
+
# quoting is the shell word it is.
|
|
782
|
+
[[ "$entry" != */ && "$entry" != *[[:space:]]* ]] && BUFFER+=" "
|
|
783
|
+
# What the commit wrote is the line the user took. Without this, the space a
|
|
784
|
+
# bare-word commit writes is one deletion from the learned line itself, and
|
|
785
|
+
# the loose glimpse answered the very next redraw with the line just taken —
|
|
786
|
+
# `cd tzz_dir ` re-offering `cd tzz_dir` from under the cursor. The same
|
|
787
|
+
# dismiss an Up from a glimpse sets: while the text stands, the list does not
|
|
788
|
+
# spring back, and the next keystroke re-arms it.
|
|
789
|
+
_TAI_MENU_DISMISS="$BUFFER"
|
|
772
790
|
CURSOR=${#BUFFER}
|
|
773
791
|
_tai_menu_close
|
|
774
792
|
}
|
package/plugins/zsh/widgets.zsh
CHANGED
|
@@ -762,6 +762,12 @@ _tai_preexec() {
|
|
|
762
762
|
# resolved in the background write instead, which also means the repo, branch
|
|
763
763
|
# and cwd all describe the same moment. bash has always done it this way.
|
|
764
764
|
_TAI_CMD="$1"
|
|
765
|
+
# A completion's own space rides the line into preexec (`nano ` after a menu
|
|
766
|
+
# choice), and the store strips it on the way in — but this shell's next
|
|
767
|
+
# prediction looks the last command up in the sequence table by its exact
|
|
768
|
+
# text, and the table's keys are the stored, stripped rows. Trim here, once
|
|
769
|
+
# per command, so `nano ` predicts what followed `nano`.
|
|
770
|
+
while [[ "$_TAI_CMD" == *[[:space:]] ]]; do _TAI_CMD="${_TAI_CMD%?}"; done
|
|
765
771
|
# The directory the command was *run from*. A prompt-time $PWD comes after the
|
|
766
772
|
# command, so for `cd src` the recorded origin was the destination — and the
|
|
767
773
|
# path-liveness check judged the cd's argument against the place it had just
|
package/readme.md
CHANGED
|
@@ -38,7 +38,7 @@ exec zsh
|
|
|
38
38
|
✓ bash installed
|
|
39
39
|
|
|
40
40
|
1. Type a few letters — tai finishes the command in grey. → takes it.
|
|
41
|
-
2. Tab opens a menu of what fits. Enter picks; a second Enter runs.
|
|
41
|
+
2. Tab opens a menu of what fits. Enter picks, space after the command included; a second Enter runs.
|
|
42
42
|
3. It learns from your history — offline, private, no account.
|
|
43
43
|
|
|
44
44
|
Try it now: type "cd " and watch the grey.
|
package/tai/cli.py
CHANGED
|
@@ -381,14 +381,19 @@ def _auto_maintain(total: int, newest_ts: int = 0) -> None:
|
|
|
381
381
|
def _rebuild_when_stale(newest_ts: int | None = None) -> None:
|
|
382
382
|
import time
|
|
383
383
|
|
|
384
|
-
from tai.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
384
|
+
from tai.paths import index_location
|
|
385
|
+
|
|
386
|
+
# The index is named as a plain string and stat'd with os: importing
|
|
387
|
+
# tai.index to ask `Path.exists` pulled the engine, the knowledge loader
|
|
388
|
+
# and pathlib into every record and flush for one stat that usually says
|
|
389
|
+
# "fresh enough". The rebuild imports are inside the two branches that
|
|
390
|
+
# rebuild, so a debounce hit is one stat and one comparison.
|
|
391
|
+
idx = index_location()
|
|
392
|
+
if not os.path.exists(idx):
|
|
393
|
+
from tai.maintenance import _rebuild_quietly
|
|
389
394
|
_rebuild_quietly()
|
|
390
395
|
return
|
|
391
|
-
mtime =
|
|
396
|
+
mtime = os.stat(idx).st_mtime
|
|
392
397
|
if time.time() - mtime < _REBUILD_MIN_INTERVAL:
|
|
393
398
|
return
|
|
394
399
|
# The newest row's timestamp arrives from the same connection that did the
|
|
@@ -410,6 +415,7 @@ def _rebuild_when_stale(newest_ts: int | None = None) -> None:
|
|
|
410
415
|
return
|
|
411
416
|
newest = row[0] if row and row[0] else 0
|
|
412
417
|
if newest and newest > mtime:
|
|
418
|
+
from tai.maintenance import _rebuild_quietly
|
|
413
419
|
_rebuild_quietly()
|
|
414
420
|
|
|
415
421
|
|
package/tai/engcache.py
CHANGED
|
@@ -28,7 +28,11 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import marshal
|
|
30
30
|
import os
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
# `pathlib` is deliberately not imported at module level: the one-shot suggest
|
|
33
|
+
# loads this module on its warm path, and a warm cache reads no `Path` — only
|
|
34
|
+
# `_cache_path`'s Path-in-Path-out contract for tests and tools that pass one
|
|
35
|
+
# needs the type, and that import happens on the first call that wants it.
|
|
32
36
|
|
|
33
37
|
# Bump when the cached shape changes (new fields on CmdStats, new maps, a new
|
|
34
38
|
# serialization). 2: CmdStats gained `nf`. 3: pickle gave way to marshal, and
|
|
@@ -41,26 +45,46 @@ VERSION = 4
|
|
|
41
45
|
_MAGIC = "tai-engine-m"
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
def _cache_path(db
|
|
48
|
+
def _cache_path(db) -> "Path | str":
|
|
45
49
|
# Beside the database, named for it: a scratch TAI_DB in a test cannot
|
|
46
50
|
# stamp a cache over the developer's real one, and `tai uninstall` (which
|
|
47
51
|
# removes the whole data directory) removes the cache with everything else.
|
|
52
|
+
# A `Path` in, a `Path` out; a string in, a string out — the one-shot path
|
|
53
|
+
# passes the string form so a warm process imports no pathlib at all.
|
|
48
54
|
import hashlib
|
|
49
55
|
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
dsp = os.fspath(db)
|
|
57
|
+
name = hashlib.sha1(dsp.encode("utf-8", "replace")).hexdigest()[:16]
|
|
58
|
+
out = os.path.join(os.path.dirname(dsp), f"engine-{name}.cache")
|
|
59
|
+
if type(db) is str:
|
|
60
|
+
return out
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
return Path(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sibling(cp, name: str):
|
|
66
|
+
"""`name` in the directory `cp` lives in, in `cp`'s own kind."""
|
|
67
|
+
out = os.path.join(os.path.dirname(os.fspath(cp)), name)
|
|
68
|
+
if type(cp) is str:
|
|
69
|
+
return out
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
return Path(out)
|
|
52
72
|
|
|
53
73
|
|
|
54
|
-
def get(db_path
|
|
74
|
+
def get(db_path, build) -> object:
|
|
55
75
|
"""The built engine for `db_path`, loading a cached one when it is current.
|
|
56
76
|
|
|
57
|
-
`
|
|
58
|
-
|
|
77
|
+
`db_path` may be a `Path` or a plain string — the one-shot path passes the
|
|
78
|
+
string form (`tai.paths.db_location`) so a warm process never imports
|
|
79
|
+
pathlib. `build` is called with no arguments and must return the engine;
|
|
80
|
+
it runs at most once per process, and only when the cache is missing or
|
|
81
|
+
stale.
|
|
59
82
|
"""
|
|
60
83
|
if os.environ.get("TAI_NO_ENGINE_CACHE") == "1":
|
|
61
84
|
return build()
|
|
85
|
+
dsp = os.fspath(db_path)
|
|
62
86
|
try:
|
|
63
|
-
st =
|
|
87
|
+
st = os.stat(dsp)
|
|
64
88
|
except OSError:
|
|
65
89
|
return build() # no store yet: nothing to cache against
|
|
66
90
|
key = (st.st_size, st.st_mtime_ns)
|
|
@@ -76,11 +100,12 @@ def get(db_path: Path, build) -> object:
|
|
|
76
100
|
pass # torn, stale, or foreign: build instead
|
|
77
101
|
eng = build()
|
|
78
102
|
try:
|
|
79
|
-
tmp = cp
|
|
103
|
+
tmp = _sibling(cp, (cp.name if type(cp) is not str
|
|
104
|
+
else os.path.basename(cp)) + f".tmp{os.getpid()}")
|
|
80
105
|
with open(tmp, "wb") as f:
|
|
81
106
|
f.write(f"{_MAGIC} {VERSION} {key[0]} {key[1]}\n".encode("ascii"))
|
|
82
107
|
marshal.dump(_snapshot(eng), f, 4)
|
|
83
|
-
os.replace(tmp, cp)
|
|
108
|
+
os.replace(os.fspath(tmp), os.fspath(cp))
|
|
84
109
|
except Exception:
|
|
85
110
|
pass # a read-only or vanished directory is fine
|
|
86
111
|
return eng
|
package/tai/engine.py
CHANGED
|
@@ -38,6 +38,14 @@ _LOG1P20 = math.log1p(20)
|
|
|
38
38
|
# Half-life of a command's recency, in days, as `exp(-age / TAU)`.
|
|
39
39
|
_TAU = 14.0
|
|
40
40
|
|
|
41
|
+
# The store's longest-learned-command bound, imported from tai.store on first
|
|
42
|
+
# use and kept here. `add` used to run `from tai.store import MAX_CMD_LEN`
|
|
43
|
+
# on *every* call — a sys.modules lookup and attribute fetch each time, 20k
|
|
44
|
+
# of them in one build_from_rows — because a module-level import would have
|
|
45
|
+
# dragged sqlite3 and re into every process that loads the engine without
|
|
46
|
+
# ever learning. The lazy global keeps the lazy import and pays it once.
|
|
47
|
+
_MAX_CMD_LEN: int | None = None
|
|
48
|
+
|
|
41
49
|
|
|
42
50
|
def hour_bucket(ts: int | None = None) -> int:
|
|
43
51
|
try:
|
|
@@ -157,8 +165,11 @@ class Engine:
|
|
|
157
165
|
return
|
|
158
166
|
# The same bound is_recordable applies — imported here, where a row this
|
|
159
167
|
# long is rare, rather than at module load, which every fast process pays.
|
|
160
|
-
|
|
161
|
-
if
|
|
168
|
+
global _MAX_CMD_LEN
|
|
169
|
+
if _MAX_CMD_LEN is None:
|
|
170
|
+
from tai.store import MAX_CMD_LEN as _mcl
|
|
171
|
+
_MAX_CMD_LEN = _mcl
|
|
172
|
+
if len(cmd) > _MAX_CMD_LEN:
|
|
162
173
|
return
|
|
163
174
|
ts = ts if ts is not None else int(time.time())
|
|
164
175
|
st = self.cmds.get(cmd)
|
|
@@ -266,10 +277,15 @@ class Engine:
|
|
|
266
277
|
on_path=None) -> dict:
|
|
267
278
|
"""Rank candidates for `prefix`.
|
|
268
279
|
|
|
269
|
-
`exclude` drops candidates before ranking.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
280
|
+
`exclude` drops candidates before ranking. It is read with `in` — a
|
|
281
|
+
set of names, or any per-name verdict holder such as the predictor's
|
|
282
|
+
`LazyStale`, which answers a candidate's path-liveness question the
|
|
283
|
+
first time it is consulted and remembers it — so a caller with an
|
|
284
|
+
expensive exclusion question pays it only for the names this query
|
|
285
|
+
actually walks. The engine itself never touches the filesystem — it
|
|
286
|
+
has to stay a sub-millisecond in-memory ranker — so the caller decides
|
|
287
|
+
policy (currently: commands whose paths no longer exist) and passes
|
|
288
|
+
the answer in.
|
|
273
289
|
|
|
274
290
|
`on_path` is the caller's answer to one question — is this command's
|
|
275
291
|
first word installed? — asked at most once per candidate, and only for
|
package/tai/fresh.py
CHANGED
|
@@ -34,9 +34,12 @@ below and by `TAI_FILE_ROOTS`, which adds roots to both.
|
|
|
34
34
|
"""
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
|
-
import glob
|
|
38
37
|
import os
|
|
39
38
|
|
|
39
|
+
# `glob` is deliberately not imported at module level: it drags fnmatch and re
|
|
40
|
+
# (about 3ms) into every one-shot process, and only a query whose answer comes
|
|
41
|
+
# from the disk ever globs. The functions below import it on first use.
|
|
42
|
+
|
|
40
43
|
# How many files one root may contribute, and how many roots are read in all.
|
|
41
44
|
# Both are shortlists, not truncation: the way to see more is to type more of
|
|
42
45
|
# the word, which narrows every root at once. A downloads folder has a season of
|
|
@@ -159,7 +162,9 @@ def _scan(pattern: str, cut: int, written: str) -> list[tuple[float, str]]:
|
|
|
159
162
|
built from mtimes, so the cap is applied after the sort.
|
|
160
163
|
"""
|
|
161
164
|
try:
|
|
162
|
-
|
|
165
|
+
from glob import glob as _glob
|
|
166
|
+
|
|
167
|
+
names = _glob(pattern)
|
|
163
168
|
except (OSError, ValueError):
|
|
164
169
|
return []
|
|
165
170
|
out: list[tuple[float, str]] = []
|
package/tai/index.py
CHANGED
|
@@ -140,7 +140,13 @@ def bash_index_path() -> Path:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def _zq(value: str) -> str:
|
|
143
|
-
|
|
143
|
+
# The apostrophe is the rare case, and the scan for it is a C-speed `in`;
|
|
144
|
+
# quoting the common case was one str.replace call per index entry — 38k
|
|
145
|
+
# of them a rebuild — for a replace that never had anything to replace.
|
|
146
|
+
value = str(value)
|
|
147
|
+
if "'" not in value:
|
|
148
|
+
return f"'{value}'"
|
|
149
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
144
150
|
|
|
145
151
|
|
|
146
152
|
def _word_value(values: list[str]) -> str:
|
package/tai/maintenance.py
CHANGED
|
@@ -7,10 +7,14 @@ eventually reclaims it — a lock that outlives its process would stop every
|
|
|
7
7
|
future rebuild, silently, forever.
|
|
8
8
|
"""
|
|
9
9
|
import os
|
|
10
|
-
import shutil
|
|
11
10
|
import time
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
|
|
13
|
+
# `shutil` is imported by the two functions that remove things, not at module
|
|
14
|
+
# level: this module loads on the record path's freshness check, and shutil
|
|
15
|
+
# dragged ~1.3ms of bz2/lzma/fnmatch with it for a question that usually ends
|
|
16
|
+
# in one stat.
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
def _lock_path() -> Path:
|
|
16
20
|
from tai.store import db_path
|
|
@@ -63,6 +67,8 @@ def _owner_gone(lock: Path) -> bool:
|
|
|
63
67
|
running = True # no usable pid: assume a live holder
|
|
64
68
|
if running:
|
|
65
69
|
return False # this boot, this minute, and still running
|
|
70
|
+
import shutil
|
|
71
|
+
|
|
66
72
|
shutil.rmtree(lock, ignore_errors=True)
|
|
67
73
|
return True
|
|
68
74
|
|
|
@@ -127,6 +133,8 @@ def _rebuild_index():
|
|
|
127
133
|
the ones that do not go through _rebuild_quietly.
|
|
128
134
|
"""
|
|
129
135
|
from tai.index import build
|
|
136
|
+
import shutil
|
|
137
|
+
|
|
130
138
|
lock = _lock_path()
|
|
131
139
|
if not _take_lock(lock):
|
|
132
140
|
return None
|
package/tai/paths.py
CHANGED
|
@@ -30,7 +30,15 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
import os
|
|
32
32
|
from functools import lru_cache
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
# `pathlib` is deliberately NOT imported at module level. This module sits under
|
|
35
|
+
# everything: the engine (which the keystroke-free one-shot path loads), the
|
|
36
|
+
# store, and the spool, so a module-level import made every one of those
|
|
37
|
+
# processes pay pathlib's own ~7ms — fnmatch, re and urllib.parse with it — for
|
|
38
|
+
# a type only two builders of file names ever need. The functions here that
|
|
39
|
+
# hand back a `Path` import it on first call instead; every other reader of
|
|
40
|
+
# this module, and the warm engine-cache suggest path above all, imports
|
|
41
|
+
# nothing heavier than `os`.
|
|
34
42
|
|
|
35
43
|
# Verdicts. Only MISSING removes a candidate.
|
|
36
44
|
EXISTS = "exists"
|
|
@@ -149,8 +157,28 @@ _UNANALYSABLE = set('$`*?[]{}<>|;&()!#\\\n\t"\'')
|
|
|
149
157
|
|
|
150
158
|
# Cached (token, cwd) verdicts. Builds and purges ask about the same paths
|
|
151
159
|
# thousands of times, and the filesystem does not change under one run.
|
|
160
|
+
# Absolute and `~` tokens are cached under the empty cwd: they are true or
|
|
161
|
+
# false in every directory, so one answer serves them all.
|
|
152
162
|
_cache: dict[tuple[str, str], bool] = {}
|
|
153
163
|
|
|
164
|
+
# Directories already judged with `os.path.isdir`, per process. `_resolve` used
|
|
165
|
+
# to re-ask it for every token of every command against every recorded cwd —
|
|
166
|
+
# a dozen stats per command on a build, and the same few directories every
|
|
167
|
+
# time. The filesystem does not change under one run; that is the same promise
|
|
168
|
+
# `_cache` above is kept under.
|
|
169
|
+
_isdir: set[str] = set()
|
|
170
|
+
_notdir: set[str] = set()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_dir(path: str) -> bool:
|
|
174
|
+
if path in _isdir:
|
|
175
|
+
return True
|
|
176
|
+
if path in _notdir:
|
|
177
|
+
return False
|
|
178
|
+
hit = os.path.isdir(path)
|
|
179
|
+
(_isdir if hit else _notdir).add(path)
|
|
180
|
+
return hit
|
|
181
|
+
|
|
154
182
|
# Every account name on this machine, or None where they cannot be read. Read
|
|
155
183
|
# once and only when a `~name` token actually turns up, because most machines
|
|
156
184
|
# have no such command in their history at all and this is a question about the
|
|
@@ -184,10 +212,38 @@ def data_dir() -> Path:
|
|
|
184
212
|
~/.local/share would split the install across two directories and leave
|
|
185
213
|
`tai uninstall` deleting one half of it and reporting complete.
|
|
186
214
|
"""
|
|
215
|
+
from pathlib import Path
|
|
216
|
+
|
|
187
217
|
base = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
|
188
218
|
return Path(base) / "tai"
|
|
189
219
|
|
|
190
220
|
|
|
221
|
+
def data_location() -> str:
|
|
222
|
+
"""`data_dir()` as a plain string, with pathlib still unpaid.
|
|
223
|
+
|
|
224
|
+
The string form of the same decision, for the processes that join file
|
|
225
|
+
names onto it and never want a `Path`: `os.path.expanduser("~")` is the
|
|
226
|
+
same home `Path.home()` reads, and the join is the same join.
|
|
227
|
+
"""
|
|
228
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
229
|
+
return os.path.join(base, "tai")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def db_location() -> str:
|
|
233
|
+
"""`db_path()` as a plain string, with pathlib still unpaid.
|
|
234
|
+
|
|
235
|
+
The engine cache is keyed on the database file's own (size, mtime_ns), so
|
|
236
|
+
the one-shot suggest asks for this path before it can ask whether it needs
|
|
237
|
+
the store at all — and a warm cache reads neither sqlite3, re, nor
|
|
238
|
+
pathlib. Every caller that wants the type wraps this in `Path` via
|
|
239
|
+
`db_path()`; nothing else about the decision changes.
|
|
240
|
+
"""
|
|
241
|
+
env = os.environ.get("TAI_DB")
|
|
242
|
+
if env:
|
|
243
|
+
return env
|
|
244
|
+
return os.path.join(data_location(), "history.db")
|
|
245
|
+
|
|
246
|
+
|
|
191
247
|
def db_path() -> Path:
|
|
192
248
|
"""Where the history store lives — `TAI_DB`, or beside `data_dir()`.
|
|
193
249
|
|
|
@@ -201,7 +257,23 @@ def db_path() -> Path:
|
|
|
201
257
|
re-export, because the writer and every reader of the store still say
|
|
202
258
|
`store.db_path`.
|
|
203
259
|
"""
|
|
204
|
-
|
|
260
|
+
from pathlib import Path
|
|
261
|
+
|
|
262
|
+
return Path(db_location())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def index_location() -> str:
|
|
266
|
+
"""The zsh index as a plain string — `TAI_INDEX`, or beside the data.
|
|
267
|
+
|
|
268
|
+
The string twin of `tai.index.index_path`, for the readers that only stat
|
|
269
|
+
the file: the record path's rebuild debounce asks "is the index older
|
|
270
|
+
than the newest row" once per recorded batch, and importing tai.index for
|
|
271
|
+
that stat pulled the engine, the knowledge loader and pathlib into every
|
|
272
|
+
flush. The env var is read identically, so the two names stay one
|
|
273
|
+
decision.
|
|
274
|
+
"""
|
|
275
|
+
return os.environ.get("TAI_INDEX") or os.path.join(
|
|
276
|
+
data_location(), "zsh-index.zsh")
|
|
205
277
|
|
|
206
278
|
|
|
207
279
|
def _unquote(token: str) -> str:
|
|
@@ -210,8 +282,18 @@ def _unquote(token: str) -> str:
|
|
|
210
282
|
return token
|
|
211
283
|
|
|
212
284
|
|
|
285
|
+
@lru_cache(maxsize=8192)
|
|
213
286
|
def is_path_like(token: str) -> bool:
|
|
214
|
-
"""True when `token` can only be read as a filesystem path.
|
|
287
|
+
"""True when `token` can only be read as a filesystem path.
|
|
288
|
+
|
|
289
|
+
Memoised: it is a pure function of the token, and the liveness walks ask it
|
|
290
|
+
for every token of every distinct command — the same few thousand strings
|
|
291
|
+
over and over, since a history repeats its paths. Measured on a 20k-row
|
|
292
|
+
history, the per-character scan was 58% of a warm `tai suggest`'s process
|
|
293
|
+
and a quarter of `tai refresh`; the cache turns both into one C-speed dict
|
|
294
|
+
hit per distinct token. Bounded, so a long-running dashboard cannot grow
|
|
295
|
+
it without limit.
|
|
296
|
+
"""
|
|
215
297
|
if not token or token.startswith("-") or "=" in token:
|
|
216
298
|
return False
|
|
217
299
|
if any(ch in _UNANALYSABLE for ch in token):
|
|
@@ -319,10 +401,14 @@ def _resolve(token: str, cwds) -> str:
|
|
|
319
401
|
return UNKNOWN
|
|
320
402
|
return EXISTS if os.path.exists(os.path.expanduser(token)) else MISSING
|
|
321
403
|
if os.path.isabs(token):
|
|
322
|
-
|
|
404
|
+
key = (token, "")
|
|
405
|
+
hit = _cache.get(key)
|
|
406
|
+
if hit is None:
|
|
407
|
+
hit = _cache[key] = os.path.exists(token)
|
|
408
|
+
return EXISTS if hit else MISSING
|
|
323
409
|
# A relative path is only knowable from a directory it was actually run in.
|
|
324
410
|
# With no such directory the honest answer is "no opinion".
|
|
325
|
-
known = [c for c in cwds if c and
|
|
411
|
+
known = [c for c in cwds if c and _is_dir(c)]
|
|
326
412
|
if not known:
|
|
327
413
|
return UNKNOWN
|
|
328
414
|
for cwd in known:
|
package/tai/predictor.py
CHANGED
|
@@ -14,10 +14,11 @@ import time
|
|
|
14
14
|
|
|
15
15
|
from tai.engine import WRAPPERS, Engine
|
|
16
16
|
from tai.fresh import files
|
|
17
|
-
from tai.paths import alive_in,
|
|
17
|
+
from tai.paths import (MISSING, alive_in, command_verdict, db_location,
|
|
18
|
+
enabled, takes_file)
|
|
18
19
|
|
|
19
20
|
_E: Engine | None = None
|
|
20
|
-
_STALE: frozenset = frozenset()
|
|
21
|
+
_STALE: "LazyStale | frozenset" = frozenset()
|
|
21
22
|
|
|
22
23
|
# Is this command's first word installed? Asked once per word per process and
|
|
23
24
|
# kept here, because PATH does not change under a running process in any way
|
|
@@ -29,25 +30,126 @@ _STALE: frozenset = frozenset()
|
|
|
29
30
|
_WHICH: dict[str, bool] = {}
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
def _path_lookup(word: str) -> bool:
|
|
34
|
+
"""`shutil.which(word) is not None`, answered without importing shutil.
|
|
35
|
+
|
|
36
|
+
shutil was 5ms of a warm one-shot suggest — and it dragged re, fnmatch,
|
|
37
|
+
bz2 and lzma in behind it — for a PATH scan this module can do with os
|
|
38
|
+
alone. The walk is shutil.which's own, POSIX branch, line for line: a
|
|
39
|
+
word with a directory part is looked up directly; an empty PATH element
|
|
40
|
+
means the current directory; a hit must exist, be executable, and not be
|
|
41
|
+
a directory. Only the Windows PATHEXT machinery is absent — the plugin
|
|
42
|
+
and the installer are Linux-only anyway.
|
|
43
|
+
"""
|
|
44
|
+
dirname, name = os.path.split(word)
|
|
45
|
+
if dirname:
|
|
46
|
+
dirs = [dirname]
|
|
47
|
+
else:
|
|
48
|
+
path = os.environ.get("PATH")
|
|
49
|
+
if path is None:
|
|
50
|
+
try:
|
|
51
|
+
path = os.confstr("CS_PATH")
|
|
52
|
+
except (AttributeError, ValueError):
|
|
53
|
+
path = os.defpath
|
|
54
|
+
if not path:
|
|
55
|
+
return False
|
|
56
|
+
dirs = os.fsdecode(path).split(os.pathsep)
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
for d in dirs:
|
|
59
|
+
norm = os.path.normcase(d)
|
|
60
|
+
if norm in seen:
|
|
61
|
+
continue
|
|
62
|
+
seen.add(norm)
|
|
63
|
+
cand = os.path.join(d, name)
|
|
64
|
+
if (os.path.exists(cand) and os.access(cand, os.F_OK | os.X_OK)
|
|
65
|
+
and not os.path.isdir(cand)):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
32
70
|
def _on_path(word: str) -> bool:
|
|
33
71
|
"""Whether `word` resolves to an executable on this machine's PATH."""
|
|
34
|
-
import shutil
|
|
35
72
|
hit = _WHICH.get(word)
|
|
36
73
|
if hit is None:
|
|
37
|
-
hit = _WHICH[word] =
|
|
74
|
+
hit = _WHICH[word] = _path_lookup(word)
|
|
38
75
|
return hit
|
|
39
76
|
|
|
40
77
|
|
|
41
|
-
|
|
42
|
-
"""
|
|
78
|
+
class LazyStale:
|
|
79
|
+
"""The engine's stale set, asked lazily and remembered per command.
|
|
80
|
+
|
|
81
|
+
`paths.stale_in` walks every distinct command's every token up front, and
|
|
82
|
+
the one-shot suggest paid that whole walk — measured at more than half of
|
|
83
|
+
its process on a 20k-row history — before ranking a handful of candidates
|
|
84
|
+
for one prefix. The verdict for one command is a pure function of the
|
|
85
|
+
command, the directories it was recorded in, and the directory the answer
|
|
86
|
+
is shown in, so it can be asked per candidate instead: the engine consults
|
|
87
|
+
`exclude` for exactly the names it would have filtered, and only those
|
|
88
|
+
verdicts are ever computed. For `tai suggest git ` that is the ~300
|
|
89
|
+
candidates behind the prefix instead of every command in the store; for an
|
|
90
|
+
empty prefix it is the same walk, deferred.
|
|
91
|
+
|
|
92
|
+
The full walk is still available — `materialize`, for the one reader that
|
|
93
|
+
counts the set, the dashboard's state panel. Behaves like a set for every
|
|
94
|
+
question the engine and the web page ask: `in`, `len`, truthiness.
|
|
95
|
+
|
|
96
|
+
The evidence rule is `stale_commands`' own, verbatim: a command is stale
|
|
97
|
+
when any of its path-like tokens is MISSING, judged from the directories
|
|
98
|
+
it was recorded in plus the one fallback directory (the cwd the answer is
|
|
99
|
+
shown in, appended only when the records do not already have it), and
|
|
100
|
+
`TAI_SKIP_PATH_CHECK=1` turns the whole question off.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
__slots__ = ("_eng", "_cwd", "_memo")
|
|
104
|
+
|
|
105
|
+
def __init__(self, eng: Engine, cwd: str = ""):
|
|
106
|
+
self._eng = eng
|
|
107
|
+
self._cwd = cwd or ""
|
|
108
|
+
self._memo: dict[str, bool] = {}
|
|
109
|
+
|
|
110
|
+
def _ask(self, cmd: str) -> bool:
|
|
111
|
+
hit = self._memo.get(cmd)
|
|
112
|
+
if hit is None:
|
|
113
|
+
if not enabled():
|
|
114
|
+
return False
|
|
115
|
+
st = self._eng.cmds.get(cmd)
|
|
116
|
+
cwds = list(st.cwd) if st is not None else []
|
|
117
|
+
if self._cwd and self._cwd not in cwds:
|
|
118
|
+
cwds.append(self._cwd)
|
|
119
|
+
hit = self._memo[cmd] = command_verdict(cmd, cwds) == MISSING
|
|
120
|
+
return hit
|
|
121
|
+
|
|
122
|
+
def __call__(self, cmd: str) -> bool:
|
|
123
|
+
return self._ask(cmd)
|
|
124
|
+
|
|
125
|
+
def __contains__(self, cmd: str) -> bool:
|
|
126
|
+
return self._ask(cmd)
|
|
127
|
+
|
|
128
|
+
def __bool__(self) -> bool:
|
|
129
|
+
# Truthy so the engine always consults the verdicts; an all-alive
|
|
130
|
+
# history filters to exactly what it filtered when the walk was eager.
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def __len__(self) -> int:
|
|
134
|
+
self.materialize()
|
|
135
|
+
return sum(1 for v in self._memo.values() if v)
|
|
136
|
+
|
|
137
|
+
def materialize(self) -> "LazyStale":
|
|
138
|
+
"""Ask every command in the engine once, so `len` is the full count."""
|
|
139
|
+
for cmd in self._eng.cmds:
|
|
140
|
+
self._ask(cmd)
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _stale_for(eng: Engine) -> LazyStale:
|
|
145
|
+
"""The stale question for this engine, asked lazily.
|
|
43
146
|
|
|
44
147
|
The one-shot suggestion is shown in the directory the suggest process runs
|
|
45
148
|
in, so the fallback cwd is passed explicitly here — and only here. The
|
|
46
149
|
index builder uses recorded evidence alone, because its snapshot is shown
|
|
47
150
|
in every directory, not just this one.
|
|
48
151
|
"""
|
|
49
|
-
|
|
50
|
-
return stale_in(eng, os.getcwd())
|
|
152
|
+
return LazyStale(eng, os.getcwd())
|
|
51
153
|
|
|
52
154
|
|
|
53
155
|
def _build_engine() -> Engine:
|
|
@@ -87,10 +189,12 @@ def suggest(prefix: str = "", cwd: str = "", repo: str = "",
|
|
|
87
189
|
if _E is None:
|
|
88
190
|
# A fresh process rebuilds the engine from SQLite unless a disk cache
|
|
89
191
|
# taken by an earlier process is still current — see tai/engcache.py.
|
|
90
|
-
# The path-liveness set is deliberately NOT cached: it
|
|
91
|
-
# the directory this process runs in, which changes
|
|
192
|
+
# The path-liveness set is deliberately NOT cached across processes: it
|
|
193
|
+
# answers against the directory this process runs in, which changes
|
|
194
|
+
# call to call. The store is named as a plain string so a warm cache
|
|
195
|
+
# imports no pathlib, no sqlite3 and no re — see tai.paths.db_location.
|
|
92
196
|
from tai.engcache import get
|
|
93
|
-
_E = get(
|
|
197
|
+
_E = get(db_location(), _build_engine)
|
|
94
198
|
_STALE = _stale_for(_E)
|
|
95
199
|
res = _E.suggest(prefix=prefix or "", cwd=cwd, repo=repo, branch=branch,
|
|
96
200
|
last_commands=last_commands or [], limit=limit, temp=temp,
|
|
@@ -138,7 +242,7 @@ def explain(prefix: str, cmd: str, cwd: str = "", repo: str = "",
|
|
|
138
242
|
global _E, _STALE
|
|
139
243
|
if _E is None:
|
|
140
244
|
from tai.engcache import get
|
|
141
|
-
_E = get(
|
|
245
|
+
_E = get(db_location(), _build_engine)
|
|
142
246
|
_STALE = _stale_for(_E)
|
|
143
247
|
out = _E.explain(prefix or "", cmd, cwd=cwd, repo=repo, branch=branch,
|
|
144
248
|
last_commands=last_commands or [], on_path=_on_path)
|
package/tai/spool.py
CHANGED
|
@@ -33,6 +33,8 @@ from __future__ import annotations
|
|
|
33
33
|
import os
|
|
34
34
|
import time
|
|
35
35
|
|
|
36
|
+
from tai.paths import data_location
|
|
37
|
+
|
|
36
38
|
RS = "\x1e"
|
|
37
39
|
US = "\x1f"
|
|
38
40
|
|
|
@@ -59,7 +61,9 @@ def spool_path() -> str:
|
|
|
59
61
|
The answer is a plain path string, not a `Path`: this module loads on the
|
|
60
62
|
record fallback and the flush, and importing pathlib costs about 9ms of
|
|
61
63
|
enum, re and urllib.parse — per process, for what is here only string
|
|
62
|
-
plumbing. The
|
|
64
|
+
plumbing. The default is joined with os.path from the same base
|
|
65
|
+
`tai.paths.data_location` reads, so the module imports nothing heavier
|
|
66
|
+
than `os`; the one caller that genuinely wants a `Path` wraps it itself.
|
|
63
67
|
"""
|
|
64
68
|
env = os.environ.get("TAI_SPOOL")
|
|
65
69
|
if env:
|
|
@@ -69,8 +73,7 @@ def spool_path() -> str:
|
|
|
69
73
|
if "/" in db:
|
|
70
74
|
return db.rsplit("/", 1)[0] + "/spool.log"
|
|
71
75
|
return "spool.log"
|
|
72
|
-
|
|
73
|
-
return os.path.join(str(data_dir()), "spool.log")
|
|
76
|
+
return os.path.join(data_location(), "spool.log")
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
def spool_append(cmd: str, cwd: str = "", exit_code: int = 0,
|
package/tai/store.py
CHANGED
|
@@ -6,10 +6,15 @@ import re
|
|
|
6
6
|
import sqlite3
|
|
7
7
|
import time
|
|
8
8
|
from contextlib import contextmanager
|
|
9
|
-
from pathlib import Path
|
|
10
9
|
|
|
11
10
|
from tai.paths import enabled as paths_enabled
|
|
12
11
|
|
|
12
|
+
# `pathlib` is not imported at module level on purpose: this module loads on
|
|
13
|
+
# the record and flush paths — once per batch the shells spool — and the type
|
|
14
|
+
# is needed only by the history-import walk (`_history_files`). A module-level
|
|
15
|
+
# import made every record pay pathlib's ~4ms and its urllib.parse/ipaddress
|
|
16
|
+
# chain for a code path that runs on `tai refresh`, not on recording.
|
|
17
|
+
|
|
13
18
|
# The longest command worth learning. A paste — a curl with its headers and
|
|
14
19
|
# cookies, a heredoc — is not vocabulary: it is never retyped from memory, and
|
|
15
20
|
# painted back as a hint it wraps the screen out of existence (the report that
|
|
@@ -20,7 +25,21 @@ from tai.paths import enabled as paths_enabled
|
|
|
20
25
|
MAX_CMD_LEN = 1000
|
|
21
26
|
|
|
22
27
|
# A command must be safe to persist and useful to suggest before it is kept.
|
|
23
|
-
|
|
28
|
+
# One reject scan, not two: is_recordable runs on every row the engine builds
|
|
29
|
+
# from and every frame the spool drains, and two `.search` calls per row were
|
|
30
|
+
# 20k regex dispatches a build for what one alternation answers. The two rules
|
|
31
|
+
# it joins, in order:
|
|
32
|
+
#
|
|
33
|
+
# * a secret-shaped word — `password`, `secret`, `token`, an AWS key id,
|
|
34
|
+
# a PEM header — matched case-insensitively, scoped inside `(?i: …)` so
|
|
35
|
+
# the rest of the pattern keeps its own exact letter-cases;
|
|
36
|
+
# * a session-persistence harness envelope, matched case-sensitively.
|
|
37
|
+
_REJECT = re.compile(
|
|
38
|
+
r"(?i:password|secret|token|AKIA|-----BEGIN)"
|
|
39
|
+
r"|__[A-Za-z0-9]+_(?:START|END)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-"
|
|
40
|
+
r"[0-9a-f]{4}-[0-9a-f]{12}__"
|
|
41
|
+
r"|__[a-z0-9_]*status=\$\?"
|
|
42
|
+
)
|
|
24
43
|
|
|
25
44
|
# The raw-control-byte half of the filter, at C speed: this runs once per row
|
|
26
45
|
# the engine builds from, and the previous per-character `any(ord(ch) < 32 …)`
|
|
@@ -73,7 +92,7 @@ def is_recordable(cmd: str) -> bool:
|
|
|
73
92
|
# sequence's continuation bytes are all 0x80 and up.)
|
|
74
93
|
if _CTRL.search(cmd):
|
|
75
94
|
return False
|
|
76
|
-
return not
|
|
95
|
+
return not _REJECT.search(cmd)
|
|
77
96
|
|
|
78
97
|
|
|
79
98
|
def db_path() -> Path:
|
|
@@ -371,6 +390,8 @@ def count() -> tuple[int, int]:
|
|
|
371
390
|
|
|
372
391
|
def _history_files(home: Path) -> list[Path]:
|
|
373
392
|
"""Return supported history files, including a custom HISTFILE."""
|
|
393
|
+
from pathlib import Path
|
|
394
|
+
|
|
374
395
|
candidates: list[Path] = []
|
|
375
396
|
custom = os.environ.get("HISTFILE", "").strip()
|
|
376
397
|
if custom:
|
|
@@ -418,6 +439,8 @@ def _import_rows(con: sqlite3.Connection) -> int:
|
|
|
418
439
|
"""
|
|
419
440
|
imported = 0
|
|
420
441
|
now = int(time.time())
|
|
442
|
+
from pathlib import Path
|
|
443
|
+
|
|
421
444
|
home = Path.home()
|
|
422
445
|
for f in _history_files(home):
|
|
423
446
|
try:
|
package/tai/typo.py
CHANGED
|
@@ -254,12 +254,26 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
254
254
|
for c, bag in counts.items():
|
|
255
255
|
for ch in low[c]:
|
|
256
256
|
bag[ch] = bag.get(ch, 0) + 1
|
|
257
|
+
# The evidence each line carries, read once: the tuple a target must beat.
|
|
258
|
+
ev_of = {c: (eng.cmds[c].success, eng.cmds[c].freq, eng.cmds[c].last_ts)
|
|
259
|
+
for c in names}
|
|
257
260
|
groups: dict[str, dict[int, list[str]]] = {}
|
|
258
261
|
for c in names:
|
|
259
262
|
groups.setdefault(low[c].split(" ", 1)[0], {}).setdefault(len(c), []).append(c)
|
|
260
263
|
# The full-tie spelling to stand: all-lowercase first (commands are
|
|
261
264
|
# lowercase; the capitalised twin is the accident), byte order otherwise.
|
|
262
265
|
tkey = {c: (0, c) if low[c] == c else (1, c) for c in names}
|
|
266
|
+
# Every bucket is walked strongest-first, ties by the likelier spelling:
|
|
267
|
+
# the search can then stop at the first candidate that survives the gates,
|
|
268
|
+
# because no later one can beat it — the same evidence order the map's
|
|
269
|
+
# target rule asks for, laid out along the scan. The walk used to test the
|
|
270
|
+
# letter gates on the whole bucket and keep the best survivor; now the
|
|
271
|
+
# gates run only on the head of the bucket, the few candidates strong
|
|
272
|
+
# enough to be a target at all, which is where the map's time went.
|
|
273
|
+
for bucket_lists in groups.values():
|
|
274
|
+
for ln, members in bucket_lists.items():
|
|
275
|
+
members.sort(key=lambda c: (-ev_of[c][0], -ev_of[c][1],
|
|
276
|
+
-ev_of[c][2], tkey[c]))
|
|
263
277
|
out: dict[str, str] = {}
|
|
264
278
|
for cmd in names:
|
|
265
279
|
st = eng.cmds[cmd]
|
|
@@ -270,12 +284,24 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
270
284
|
continue
|
|
271
285
|
s_set = sets[cmd]
|
|
272
286
|
s_bag = counts[cmd]
|
|
273
|
-
ev =
|
|
274
|
-
|
|
275
|
-
|
|
287
|
+
ev = ev_of[cmd]
|
|
288
|
+
len_cmd = len(cmd)
|
|
289
|
+
best: str | None = None
|
|
290
|
+
best_oev: tuple[int, int, int] | None = None
|
|
291
|
+
for ln in range(len_cmd - 2, len_cmd + 3):
|
|
276
292
|
for other in buckets.get(ln, ()):
|
|
277
293
|
if other == cmd:
|
|
278
294
|
continue
|
|
295
|
+
oev = ev_of[other]
|
|
296
|
+
# The target must be the stronger spelling — or, on a full
|
|
297
|
+
# tie, the likelier spelling (the tie bullet above). Anything
|
|
298
|
+
# weaker is somebody else's shadow, not this line's habit.
|
|
299
|
+
# The bucket is ordered strongest-first, so the first candidate
|
|
300
|
+
# that fails here fails for every candidate after it too: one
|
|
301
|
+
# comparison ends the bucket, where the walk used to gate-test
|
|
302
|
+
# hundreds of lines that could never have been the target.
|
|
303
|
+
if oev < ev or (oev == ev and tkey[other] > tkey[cmd]):
|
|
304
|
+
break
|
|
279
305
|
o_set = sets[other]
|
|
280
306
|
if len(o_set - s_set) > 2 or len(s_set - o_set) > 2:
|
|
281
307
|
continue
|
|
@@ -290,20 +316,20 @@ def shadow_map(eng, lines=None) -> dict[str, str]:
|
|
|
290
316
|
surplus += extra
|
|
291
317
|
if surplus > 2:
|
|
292
318
|
break
|
|
293
|
-
if surplus > 2 or surplus - (
|
|
294
|
-
continue
|
|
295
|
-
ost = eng.cmds[other]
|
|
296
|
-
oev = (ost.success, ost.freq, ost.last_ts)
|
|
297
|
-
# The target must be the stronger spelling — or, on a full
|
|
298
|
-
# tie, the likelier spelling (the tie bullet above). Anything
|
|
299
|
-
# weaker is somebody else's shadow, not this line's habit.
|
|
300
|
-
if oev < ev or (oev == ev and tkey[other] > tkey[cmd]):
|
|
319
|
+
if surplus > 2 or surplus - (len_cmd - len(other)) > 2:
|
|
301
320
|
continue
|
|
302
321
|
if lev1(low[cmd], low[other], 2) > 2:
|
|
303
322
|
continue
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
323
|
+
# Strongest-first order says this survivor is the best target
|
|
324
|
+
# its own bucket holds: an equal-evidence one behind it spells
|
|
325
|
+
# itself less plausibly, and a weaker one is no target at all.
|
|
326
|
+
# The five length buckets are still compared against each
|
|
327
|
+
# other — a stronger target may sit two lengths away — so the
|
|
328
|
+
# survivor is kept and the next bucket is asked.
|
|
329
|
+
if best is None or oev > best_oev or \
|
|
330
|
+
(oev == best_oev and tkey[other] < tkey[best]):
|
|
331
|
+
best, best_oev = other, oev
|
|
332
|
+
break
|
|
307
333
|
if best is not None:
|
|
308
|
-
out[cmd] = best
|
|
334
|
+
out[cmd] = best
|
|
309
335
|
return out
|