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 CHANGED
@@ -1 +1 @@
1
- 2.3.1
1
+ 2.3.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminal-ai-helper",
3
- "version": "2.3.1",
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": {
@@ -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
- for c in "${COMPREPLY[@]}"; do [[ "$c" == "$1" ]] && return 0; done
255
- COMPREPLY+=("$1")
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
@@ -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
  }
@@ -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.index import index_path
385
- from tai.maintenance import _rebuild_quietly
386
-
387
- idx = index_path()
388
- if not idx.exists():
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 = idx.stat().st_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
- from pathlib import Path
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: Path) -> Path:
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
- name = hashlib.sha1(str(db).encode("utf-8", "replace")).hexdigest()[:16]
51
- return db.parent / f"engine-{name}.cache"
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: Path, build) -> object:
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
- `build` is called with no arguments and must return the engine; it runs at
58
- most once per process, and only when the cache is missing or stale.
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 = db_path.stat()
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.with_name(cp.name + f".tmp{os.getpid()}")
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
- from tai.store import MAX_CMD_LEN
161
- if len(cmd) > MAX_CMD_LEN:
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. The engine itself never
270
- touches the filesystem — it has to stay a sub-millisecond in-memory
271
- ranker — so the caller decides policy (currently: commands whose paths
272
- no longer exist) and passes the result in.
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
- names = glob.glob(pattern)
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
- return "'" + str(value).replace("'", "'\\''") + "'"
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:
@@ -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
- from pathlib import Path
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
- return Path(os.environ.get("TAI_DB") or data_dir() / "history.db")
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
- return EXISTS if os.path.exists(token) else MISSING
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 os.path.isdir(c)]
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, db_path, takes_file
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] = shutil.which(word) is not None
74
+ hit = _WHICH[word] = _path_lookup(word)
38
75
  return hit
39
76
 
40
77
 
41
- def _stale_for(eng: Engine) -> set:
42
- """Commands whose paths no longer exist, per the engine's own cwd records.
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
- from tai.paths import stale_in
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 answers against
91
- # the directory this process runs in, which changes call to call.
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(db_path(), _build_engine)
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(db_path(), _build_engine)
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 one caller that genuinely wants a `Path` wraps it itself.
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
- from tai.paths import data_dir
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
- _SECRET = re.compile(r"(password|secret|token|AKIA|-----BEGIN)", re.I)
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 (_SECRET.search(cmd) or _WRAPPER.search(cmd))
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 = (st.success, st.freq, st.last_ts)
274
- best: tuple[tuple[int, int, int], str] | None = None
275
- for ln in range(len(cmd) - 2, len(cmd) + 3):
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 - (len(cmd) - len(other)) > 2:
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
- if best is None or oev > best[0] or \
305
- (oev == best[0] and tkey[other] < tkey[best[1]]):
306
- best = (oev, other)
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[1]
334
+ out[cmd] = best
309
335
  return out